MMOON commited on
Commit
df42b73
·
verified ·
1 Parent(s): c2f2229

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +90 -743
src/streamlit_app.py CHANGED
@@ -1,784 +1,131 @@
1
- # streamlit_app.py
2
-
3
- import streamlit as st
4
  import requests
5
- import pandas as pd
6
- import re
7
- from datetime import datetime
8
- import json
9
- import plotly.express as px
10
- import plotly.graph_objects as go
11
  from bs4 import BeautifulSoup
 
12
  import time
13
 
14
- # Configuration de la page
15
- st.set_page_config(
16
- page_title="Moniteur Codex Alimentarius",
17
- page_icon="📋",
18
- layout="wide",
19
- initial_sidebar_state="expanded"
20
- )
21
 
22
- # URLs du Codex Alimentarius - Nettoyées
23
- CODEX_URLS = {
24
- 'guidelines': {
25
- 'name': 'Directives (CXG)',
26
- 'url': 'https://www.fao.org/fao-who-codexalimentarius/codex-texts/guidelines/fr/',
27
- 'prefix': 'CXG'
28
- },
29
- 'standards': {
30
- 'name': 'Normes (CXS)',
31
- 'url': 'https://www.fao.org/fao-who-codexalimentarius/codex-texts/list-standards/fr/',
32
- 'prefix': 'CXS'
33
- },
34
- 'codes': {
35
- 'name': 'Codes de Pratique (CXC)',
36
- 'url': 'https://www.fao.org/fao-who-codexalimentarius/codex-texts/codes-of-practice/fr/',
37
- 'prefix': 'CXC'
38
- },
39
- 'misc': {
40
- 'name': 'Documents Divers',
41
- 'url': 'https://www.fao.org/fao-who-codexalimentarius/codex-texts/miscellaneous/fr/',
42
- 'prefix': 'CXM'
43
- }
44
  }
45
 
46
- # Nettoyer les URLs au démarrage
47
- for key in CODEX_URLS:
48
- CODEX_URLS[key]['url'] = CODEX_URLS[key]['url'].strip()
49
-
50
- # Cache pour la disponibilité des PDF (TTL 10 minutes)
51
- @st.cache_data(ttl=600)
52
- def check_pdf_availability_cached(url):
53
- """Vérifie si un PDF est disponible à l'URL donnée (version mise en cache)"""
54
- return check_pdf_availability(url)
55
 
56
- @st.cache_data(ttl=3600) # Cache pour 1 heure
57
- def extract_documents_from_url(url, category):
58
- """Extrait les documents d'une page du Codex Alimentarius"""
59
- try:
60
- headers = {
61
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36'
62
- }
63
 
64
- response = requests.get(url, headers=headers, timeout=30)
65
- response.raise_for_status()
 
66
 
67
- # Parser le HTML
68
- soup = BeautifulSoup(response.content, 'html.parser')
 
69
 
70
- documents = []
71
- seen_codes = set() # Pour éviter les doublons
72
-
73
- # --- Méthode principale: Analyser les balises <table> pour trouver les documents ---
74
- # Les documents sont dans des tableaux
75
- tables = soup.find_all('table')
76
-
77
- if not tables:
78
- st.warning(f"Aucun tableau trouvé sur la page {url}.")
79
- return []
80
 
 
 
81
  # Parcourir chaque tableau
82
- for table in tables:
 
83
  rows = table.find_all('tr')
84
  for row in rows:
85
- # Chercher les cellules dans la ligne
86
  cells = row.find_all(['td', 'th']) # Inclure th au cas où
87
- # Un document valide a généralement au moins 4 cellules (code, titre, comité, année)
88
  if len(cells) >= 4:
89
  # Extraire le texte de chaque cellule
90
  cell_texts = [cell.get_text(strip=True) for cell in cells]
91
 
92
- # Essayer de trouver un code Codex dans la première cellule
93
  code_candidate = cell_texts[0] if cell_texts else ""
94
- # Amélioration du pattern regex pour gérer les tirets et 'R'
95
- code_match = re.match(r'^(CX[GSCXM])\s*([\w\-]*\d+(?:-\d+)?[R]?)$', code_candidate)
96
 
97
  if code_match:
98
  prefix = code_match.group(1)
99
  number_part = code_match.group(2)
100
  full_code = f"{prefix} {number_part}"
101
 
102
- # Extraire le titre (2ème cellule)
103
- title = cell_texts[1] if len(cell_texts) > 1 else "Titre non trouvé"
104
-
105
- # Extraire le comité (3ème cellule)
106
- committee = cell_texts[2] if len(cell_texts) > 2 else "COMITE"
107
-
108
- # Extraire l'année (4ème cellule)
109
- year_str = cell_texts[3] if len(cell_texts) > 3 else ""
110
- try:
111
- year = int(year_str) if year_str.isdigit() else 0
112
- except ValueError:
113
- year = 0 # Valeur par défaut si l'année n'est pas valide
114
-
115
  if full_code not in seen_codes:
116
  seen_codes.add(full_code)
 
 
 
 
 
 
 
 
 
 
117
  documents.append({
118
  'code': full_code,
119
  'title': title,
120
  'committee': committee,
121
- 'year': year,
122
- 'category': category,
123
- 'category_name': CODEX_URLS[category]['name'],
124
- 'is_new': year >= 2023,
125
- 'is_2024': year == 2024,
126
- 'source_url': url,
127
- 'pdf_url': get_best_pdf_link(full_code, year),
128
- 'extracted_at': datetime.now().isoformat()
129
  })
130
-
131
- # --- Méthode de secours: Parser le texte brut pour les formats alternatifs ---
132
- # Par exemple, pour les pages comme codes-of-practice qui ont un format texte brut
133
- if not documents:
134
- st.info(f"Aucun document trouvé via l'analyse des tableaux pour {CODEX_URLS[category]['name']}. Tentative via le texte brut...")
135
- text_content = soup.get_text()
136
- # Pattern pour extraire les documents dans le texte brut
137
- # Exemple: | CXC 20-1979| Code de déontologie...| CCGP| 2010||
138
- # Ce pattern gère les espaces variables et les barres verticales
139
- # Amélioration du pattern pour gérer 'R' et les tirets
140
- pattern = r'\|\s*(CX[GSCXM])\s*([\w\-]*\d+(?:-\d+)?[R]?)\s*\|\s*([^|]+?)\s*\|\s*([A-Z0-9]{2,15})\s*\|\s*(\d{4})'
141
- matches = re.findall(pattern, text_content, re.DOTALL)
142
-
143
- for match in matches:
144
- prefix, number_part, title, committee, year_str = match
145
- full_code = f"{prefix} {number_part}"
146
- title = title.strip()
147
- committee = committee.strip()
148
- try:
149
- year = int(year_str.strip())
150
- except ValueError:
151
- year = 0
152
-
153
- if full_code not in seen_codes:
154
- seen_codes.add(full_code)
155
- documents.append({
156
- 'code': full_code,
157
- 'title': title,
158
- 'committee': committee,
159
- 'year': year,
160
- 'category': category,
161
- 'category_name': CODEX_URLS[category]['name'],
162
- 'is_new': year >= 2023,
163
- 'is_2024': year == 2024,
164
- 'source_url': url,
165
- 'pdf_url': get_best_pdf_link(full_code, year),
166
- 'extracted_at': datetime.now().isoformat()
167
- })
168
-
169
- if documents:
170
- st.success(f"{len(documents)} documents extraits de {CODEX_URLS[category]['name']}")
171
- else:
172
- st.warning(f"Aucun document trouvé dans {CODEX_URLS[category]['name']} - utilisation des données de sauvegarde")
173
-
174
- return documents
175
-
176
- except requests.exceptions.RequestException as e:
177
- st.error(f"Erreur réseau lors de l'extraction de {CODEX_URLS[category]['name']}: {str(e)}")
178
- st.info("Utilisation des données de sauvegarde...")
179
- return get_fallback_data(category)
180
- except Exception as e:
181
- st.error(f"Erreur lors de l'extraction de {CODEX_URLS[category]['name']}: {str(e)}")
182
- st.info("Utilisation des données de sauvegarde...")
183
- return get_fallback_data(category)
184
-
185
-
186
- def get_fallback_data(category):
187
- """Données de sauvegarde en cas d'échec de l'extraction"""
188
- fallback_guidelines = [
189
- {'code': 'CXG 105-2024', 'title': 'Guidelines on the use of technology to provide food information in food labelling', 'committee': 'CCFL', 'year': 2024},
190
- {'code': 'CXG 104-2024', 'title': 'Guidelines on the provision of food information for pre-packaged foods to be offered via e-commerce', 'committee': 'CCFL', 'year': 2024},
191
- {'code': 'CXG 103-2024', 'title': 'Guidelines for food hygiene control measures in traditional markets for food', 'committee': 'CCFH', 'year': 2024},
192
- {'code': 'CXG 99-2023', 'title': 'Directives pour la maîtrise des Escherichia coli producteurs de shiga-toxines (stec)', 'committee': 'CCFH', 'year': 2023},
193
- {'code': 'CXG 100-2023', 'title': 'Guidelines for the Safe Use and Reuse of Water in Food Production and Processing', 'committee': 'CCFH', 'year': 2023},
194
- {'code': 'CXG 101-2023', 'title': 'Guidelines on Recognition and Maintenance of Equivalence of National Food Control Systems', 'committee': 'CCFICS', 'year': 2023},
195
- {'code': 'CXG 102-2023', 'title': 'Principles and Guidelines on the Use of Remote Audit and Inspection in Regulatory Frameworks', 'committee': 'CCFICS', 'year': 2023},
196
- {'code': 'CXG 96-2022', 'title': 'Directives pour la gestion des épidémies biologiques d’origine alimentaire', 'committee': 'CCFH', 'year': 2022},
197
- {'code': 'CXG 97-2022', 'title': 'Guidelines for the Recognition of Active Substances or Authorized Uses of Active Substances of Low Public Health Concern...', 'committee': 'CCPR', 'year': 2022},
198
- ]
199
-
200
- fallback_standards = [
201
- {'code': 'CXS 359-2024', 'title': 'Standard for dried or dehydrated roots, rhizomes and bulbs – Turmeric', 'committee': 'CCSCH', 'year': 2024},
202
- {'code': 'CXS 358-2024', 'title': 'Standard for spices derived from dried or dehydrated fruits and berries - Allspice, juniper berry and star anise', 'committee': 'CCSCH', 'year': 2024},
203
- {'code': 'CXS 357-2024', 'title': 'Standard for spices derived from dried or dehydrated fruits and berries – Small cardamom', 'committee': 'CCSCH', 'year': 2024},
204
- {'code': 'CXS 193-1995', 'title': 'Norme générale pour les contaminants et les toxines présents dans les produits de consommation humaine et animale', 'committee': 'CCCF', 'year': 2024},
205
- {'code': 'CXS 1-1985', 'title': 'Norme générale pour l\'étiquetage des denrées alimentaires préemballées', 'committee': 'CCFL', 'year': 2024},
206
- {'code': 'CXS 283-1978', 'title': 'Norme générale pour le fromage', 'committee': 'CCMMP', 'year': 2024},
207
- {'code': 'CXS 349-2022', 'title': 'Norme pour les baies', 'committee': 'CCFFV', 'year': 2022},
208
- {'code': 'CXS 352-2022', 'title': 'Norme pour les graines séchées – Noix de muscade', 'committee': 'CCSCH', 'year': 2022},
209
- {'code': 'CXS 329-2017', 'title': 'Norme pour les huiles de poisson', 'committee': 'CCFO', 'year': 2024},
210
- {'code': 'CXS 288-1976', 'title': 'Norme pour la crème et les crèmes préparées', 'committee': 'CCMMP', 'year': 2024},
211
- {'code': 'CXS 222-2001', 'title': 'Norme pour les croquettes de poisson de mer et d\'eau douce, crustacés et mollusques', 'committee': 'CCFFP', 'year': 2024},
212
- ]
213
-
214
- fallback_codes = [
215
- {'code': 'CXC 1-1969', 'title': 'Principes généraux d\'hygiène alimentaire', 'committee': 'CCFH', 'year': 2022},
216
- {'code': 'CXC 20-1979', 'title': 'Code de déontologie du commerce international des denrées alimentaires', 'committee': 'CCGP', 'year': 2010},
217
- {'code': 'CXC 58-2005', 'title': 'Code d’usages en matière d’hygiène pour la viande', 'committee': 'CCMPH', 'year': 2005},
218
- {'code': 'CXC 75-2015', 'title': 'Code d\'usages en matière d\'hygiène pour les aliments à faible teneur en eau', 'committee': 'CCFH', 'year': 2018},
219
- {'code': 'CXC 80-2020', 'title': 'Code d’usages sur la gestion des allergènes alimentaires pour les exploitants du secteur alimentaire', 'committee': 'CCFH', 'year': 2020},
220
- ]
221
-
222
- if category == 'guidelines':
223
- data = fallback_guidelines
224
- elif category == 'standards':
225
- data = fallback_standards
226
- elif category == 'codes':
227
- data = fallback_codes
228
- else:
229
- return [] # Pas de données pour misc
230
-
231
- return [
232
- {
233
- **item,
234
- 'category': category,
235
- 'category_name': CODEX_URLS[category]['name'],
236
- 'is_new': item['year'] >= 2023,
237
- 'is_2024': item['year'] == 2024,
238
- 'source_url': CODEX_URLS[category]['url'],
239
- 'pdf_url': get_best_pdf_link(item['code'], item['year']),
240
- 'extracted_at': datetime.now().isoformat()
241
- }
242
- for item in data
243
- ]
244
-
245
-
246
- def generate_pdf_links(code, year):
247
- """Génère les liens PDF potentiels pour un document Codex"""
248
- potential_urls = []
249
-
250
- # Nettoyer le code pour l'URL
251
- # Ex: CXS 12 -> CXS_012
252
- # Ex: CXG 105 -> CXG_105
253
- # Ex: CXS 12-1981 -> CXS_012-1981
254
- # Ex: CXS 298R-2009 -> CXS_298R-2009
255
- code_parts = code.split()
256
- if len(code_parts) != 2:
257
- # Format inattendu, essayer de deviner
258
- clean_code_for_url = code.replace(' ', '_')
259
  else:
260
- prefix = code_parts[0]
261
- number_part = code_parts[1]
262
-
263
- # Gérer les numéros avec tirets et 'R' (ex: 298R-2009)
264
- if '-' in number_part:
265
- parts = number_part.split('-', 1) # Split seulement sur le premier tiret
266
- main_number_str = parts[0]
267
- suffix_and_year = parts[1] if len(parts) > 1 else ""
 
 
 
 
268
  try:
269
- # Essayer de pad le numéro principal
270
- main_number = int(main_number_str.rstrip('R')) # Enlever 'R' temporairement
271
- padded_main_number = f"{main_number:03d}"
272
- # Réassembler avec 'R' si présent
273
- if main_number_str.endswith('R'):
274
- padded_main_number += 'R'
275
- clean_code_for_url = f"{prefix}_{padded_main_number}-{suffix_and_year}"
276
  except ValueError:
277
- # Si le numéro principal n'est pas un entier pur
278
- clean_code_for_url = f"{prefix}_{number_part}"
279
- else:
280
- # Pas de tiret, juste un numéro (ex: 12)
281
- try:
282
- number = int(number_part.rstrip('R'))
283
- padded_number = f"{number:03d}"
284
- if number_part.endswith('R'):
285
- padded_number += 'R'
286
- clean_code_for_url = f"{prefix}_{padded_number}"
287
- except ValueError:
288
- # Si ce n'est pas un nombre pur
289
- clean_code_for_url = f"{prefix}_{number_part}"
290
-
291
- # Chemins possibles
292
- base_paths = [
293
- "https://www.fao.org/fileadmin/templates/codexalimentarius/pdf/CODEX_STANDARDS/",
294
- "https://www.fao.org/fileadmin/user_upload/CODEX_STANDARDS/",
295
- "https://www.fao.org/fileadmin/CODEX_STANDARDS/",
296
- "https://www.fao.org/fileadmin/templates/codexalimentarius/Standards/" # Un autre chemin possible
297
- ]
298
-
299
- # Variants courants: final (f), consolidated (c), revue (r), standard (s), annex (a), english (e)
300
- # Pour les documents régionaux (R), le 'R' est déjà dans le nom du fichier
301
- variants = ['f', 'c', 'r', 's', 'a', 'e', ''] # '' pour le lien sans variant
302
-
303
- for base_path in base_paths:
304
- for variant in variants:
305
- if variant:
306
- potential_urls.append(f"{base_path}{clean_code_for_url}{variant}.pdf")
307
- else:
308
- potential_urls.append(f"{base_path}{clean_code_for_url}.pdf")
309
-
310
- return potential_urls
311
-
312
- def check_pdf_availability(url):
313
- """Vérifie si un PDF est disponible à l'URL donnée"""
314
- try:
315
- # Utiliser HEAD pour vérifier rapidement
316
- response = requests.head(url, timeout=5, allow_redirects=True)
317
- # Certains serveurs renvoient 302/301 même si le fichier existe, suivi d'une 200
318
- # Donc on vérifie si le statut final est 200
319
- # Ou si le Content-Type est application/pdf
320
- if response.status_code == 200:
321
- content_type = response.headers.get('Content-Type', '')
322
- if 'application/pdf' in content_type:
323
- return True
324
- # Si HEAD ne fonctionne pas bien, essayer GET avec un petit timeout
325
- # Mais ce n'est pas idéal pour les gros fichiers
326
- # On peut aussi vérifier si le HEAD redirige vers un PDF
327
- elif response.status_code in [301, 302, 307, 308]:
328
- # Suivre la redirection manuellement une fois pour vérifier
329
- try:
330
- final_response = requests.get(url, timeout=5, allow_redirects=True, stream=True)
331
- if final_response.status_code == 200:
332
- content_type = final_response.headers.get('Content-Type', '')
333
- if 'application/pdf' in content_type:
334
- return True
335
- final_response.close()
336
- except:
337
- pass
338
- return False
339
- except:
340
- return False
341
-
342
- def get_best_pdf_link(code, year):
343
- """Retourne le meilleur lien PDF disponible pour un document"""
344
- potential_urls = generate_pdf_links(code, year)
345
-
346
- # Tester les URLs par ordre de préférence
347
- for url in potential_urls:
348
- if check_pdf_availability_cached(url): # Utiliser la version mise en cache
349
- return url
350
-
351
- # Si aucun PDF direct trouvé, retourner le lien de recherche
352
- search_term = code.replace(' ', '%20')
353
- return f"https://www.fao.org/fao-who-codexalimentarius/search/en/?q={search_term}"
354
-
355
-
356
- def main():
357
- # Header
358
- st.title("📋 Moniteur Codex Alimentarius")
359
- st.markdown("""
360
- **Surveillance et analyse en temps réel des documents de sécurité alimentaire**
361
-
362
- Cette application extrait et analyse automatiquement les documents du Codex Alimentarius pour votre veille réglementaire en food safety.
363
- """)
364
-
365
- # Sidebar
366
- st.sidebar.header("🎛️ Configuration")
367
-
368
- # Option de source de données
369
- data_source = st.sidebar.radio(
370
- "Source des données:",
371
- ["Données d'exemple", "Extraction en temps réel"]
372
- )
373
-
374
- # Bouton de chargement
375
- if st.sidebar.button("🔄 Charger les données", type="primary"):
376
- with st.spinner("Chargement des données..."):
377
- if data_source == "Données d'exemple":
378
- st.session_state.documents = (
379
- get_fallback_data('guidelines') +
380
- get_fallback_data('standards') +
381
- get_fallback_data('codes')
382
- )
383
- st.success(f"✅ {len(st.session_state.documents)} documents d'exemple chargés!")
384
- else:
385
- # Extraction en temps réel avec gestion d'erreurs améliorée
386
- all_documents = []
387
- progress_bar = st.progress(0)
388
- status_placeholder = st.empty()
389
-
390
- # Traiter toutes les catégories
391
- categories_to_process = ['standards', 'guidelines', 'codes', 'misc']
392
-
393
- for i, category in enumerate(categories_to_process):
394
- info = CODEX_URLS[category]
395
- status_placeholder.info(f"Extraction des {info['name']}...")
396
-
397
- try:
398
- documents = extract_documents_from_url(info['url'], category)
399
- all_documents.extend(documents)
400
-
401
- except Exception as e:
402
- st.error(f"❌ Erreur avec {info['name']}: {str(e)}")
403
-
404
- progress_bar.progress((i + 1) / len(categories_to_process))
405
- time.sleep(0.3) # Pause plus courte
406
-
407
- status_placeholder.empty()
408
-
409
- if all_documents:
410
- st.session_state.documents = all_documents
411
- st.success(f"✅ {len(all_documents)} documents extraits au total!")
412
- else:
413
- st.error("❌ Aucun document extrait. Utilisation des données d'exemple...")
414
- st.session_state.documents = (
415
- get_fallback_data('guidelines') +
416
- get_fallback_data('standards') +
417
- get_fallback_data('codes')
418
- )
419
- st.info(f"📊 {len(st.session_state.documents)} documents d'exemple chargés en fallback")
420
-
421
- # Bouton pour forcer le mode démo
422
- if st.sidebar.button("🎯 Forcer le mode démo", help="Charge les données d'exemple directement"):
423
- st.session_state.documents = (
424
- get_fallback_data('guidelines') +
425
- get_fallback_data('standards') +
426
- get_fallback_data('codes')
427
- )
428
- st.success(f"✅ Mode démo activé - {len(st.session_state.documents)} documents chargés!")
429
-
430
- # Section de debug
431
- if st.sidebar.checkbox("🔧 Mode Debug"):
432
- st.sidebar.subheader("Debug Info")
433
-
434
- test_url_key = st.sidebar.selectbox(
435
- "Tester une URL:",
436
- list(CODEX_URLS.keys()),
437
- format_func=lambda x: CODEX_URLS[x]['name']
438
- )
439
-
440
- if st.sidebar.button("🔍 Tester extraction"):
441
- with st.expander("Résultats du test d'extraction", expanded=True):
442
- try:
443
- url = CODEX_URLS[test_url_key]['url']
444
- st.write(f"**URL testée:** {url}")
445
-
446
- # Test de connexion
447
- headers = {
448
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36'
449
- }
450
- with st.spinner("Connexion au site..."):
451
- response = requests.get(url, headers=headers, timeout=15)
452
- st.write(f"**Status HTTP:** {response.status_code}")
453
- st.write(f"**Taille de la réponse:** {len(response.text)} caractères")
454
-
455
- # Extraire un échantillon du texte
456
- soup = BeautifulSoup(response.content, 'html.parser')
457
- text_sample = soup.get_text()[:3000]
458
- st.text_area("Échantillon du texte extrait:", text_sample, height=200)
459
-
460
- # Analyse des structures HTML
461
- tables = soup.find_all('table')
462
- st.write(f"**Tables trouvées:** {len(tables)}")
463
- if tables:
464
- rows_in_first_table = tables[0].find_all('tr')
465
- st.write(f"**Lignes dans la première table:** {len(rows_in_first_table)}")
466
- if rows_in_first_table:
467
- first_row_cells = rows_in_first_table[0].find_all(['td', 'th'])
468
- st.write(f"**Cellules dans la première ligne:** {len(first_row_cells)}")
469
- st.write("**Contenu de la première ligne:**", [c.get_text(strip=True) for c in first_row_cells])
470
-
471
- # Test des patterns regex
472
- pattern_table = r'^(CX[GSCXM])\s*([\w\-]*\d+(?:-\d+)?[R]?)$'
473
- pattern_text = r'\|\s*(CX[GSCXM])\s*([\w\-]*\d+(?:-\d+)?[R]?)\s*\|\s*([^|]+?)\s*\|\s*([A-Z0-9]{2,15})\s*\|\s*(\d{4})'
474
-
475
- rows = soup.find_all('tr')
476
- table_matches_count = 0
477
- sample_matches = []
478
- for i, row in enumerate(rows):
479
- cells = row.find_all('td')
480
- if cells:
481
- code_candidate = cells[0].get_text(strip=True) if cells else ""
482
- match = re.match(pattern_table, code_candidate)
483
- if match:
484
- table_matches_count += 1
485
- if len(sample_matches) < 5: # Montrer les 5 premiers matches
486
- sample_matches.append((i, match.groups()))
487
-
488
- text_matches = re.findall(pattern_text, soup.get_text(), re.DOTALL)
489
-
490
- st.write(f"**Pattern Tableau:** {table_matches_count} lignes de document trouvées")
491
- if sample_matches:
492
- st.write("**Exemples de matches dans le tableau:**")
493
- for idx, groups in sample_matches:
494
- st.write(f" - Ligne {idx}: {groups}")
495
- st.write(f"**Pattern Texte Brut:** {len(text_matches)} documents trouvés")
496
- if text_matches:
497
- st.write("**Exemples de matches dans le texte brut:**", text_matches[:3])
498
-
499
- # Test d'extraction complète
500
- st.write("---")
501
- st.subheader("Extraction des documents...")
502
- with st.spinner("Extraction en cours..."):
503
- documents = extract_documents_from_url(url, test_url_key)
504
- st.write(f"**Documents extraits:** {len(documents)}")
505
- if documents:
506
- st.write("Premiers documents:", documents[:3])
507
-
508
- except Exception as e:
509
- st.error(f"Erreur lors du test: {str(e)}")
510
- import traceback
511
- st.code(traceback.format_exc())
512
-
513
- # Vérifier si on a des données
514
- if 'documents' not in st.session_state:
515
- st.info("👆 Utilisez le panneau latéral pour charger les données")
516
- return
517
-
518
- df = pd.DataFrame(st.session_state.documents)
519
-
520
- if df.empty:
521
- st.warning("Aucun document trouvé")
522
- return
523
-
524
- # Statistiques principales
525
- col1, col2, col3, col4 = st.columns(4)
526
-
527
- with col1:
528
- st.metric("📊 Total Documents", len(df))
529
-
530
- with col2:
531
- new_docs = len(df[df['is_new']])
532
- st.metric("🆕 Nouveaux (2023+)", new_docs)
533
-
534
- with col3:
535
- docs_2024 = len(df[df['is_2024']])
536
- st.metric("📅 Mis à jour 2024", docs_2024)
537
-
538
- with col4:
539
- committees = df['committee'].nunique()
540
- st.metric("🏢 Comités Actifs", committees)
541
-
542
- st.divider()
543
-
544
- # Filtres
545
- st.sidebar.header("🔍 Filtres")
546
-
547
- # Filtre par catégorie
548
- categories = ['Toutes'] + list(df['category_name'].unique())
549
- selected_category = st.sidebar.selectbox("Catégorie:", categories)
550
-
551
- # Filtre par comité
552
- committees = ['Tous'] + sorted(df['committee'].unique())
553
- selected_committee = st.sidebar.selectbox("Comité:", committees)
554
-
555
- # Filtre par année - Amélioré avec une plage
556
- st.sidebar.subheader("Année")
557
- min_year = int(df['year'].min()) if not df.empty and df['year'].min() > 0 else 1960
558
- max_year = max(int(df['year'].max()), datetime.now().year) if not df.empty else datetime.now().year
559
-
560
- # Option 1: Sélection unique
561
- # years = ['Toutes'] + sorted(df['year'].unique(), reverse=True)
562
- # selected_year = st.sidebar.selectbox("Année:", years)
563
-
564
- # Option 2: Plage d'années (plus flexible)
565
- year_range = st.sidebar.slider(
566
- "Plage d'années:",
567
- min_value=min_year,
568
- max_value=max_year,
569
- value=(min_year, max_year),
570
- step=1
571
- )
572
-
573
- # Filtre par nouveauté
574
- filter_new = st.sidebar.checkbox("Seulement les nouveaux documents (2023+)")
575
- filter_2024 = st.sidebar.checkbox("Seulement les mises à jour 2024")
576
-
577
- # Recherche textuelle
578
- search_term = st.sidebar.text_input("🔍 Recherche dans les titres ou codes:")
579
-
580
- # Application des filtres
581
- filtered_df = df.copy()
582
-
583
- if selected_category != 'Toutes':
584
- filtered_df = filtered_df[filtered_df['category_name'] == selected_category]
585
-
586
- if selected_committee != 'Tous':
587
- filtered_df = filtered_df[filtered_df['committee'] == selected_committee]
588
-
589
- # if selected_year != 'Toutes':
590
- # filtered_df = filtered_df[filtered_df['year'] == selected_year]
591
- # Filtrer par plage d'années
592
- filtered_df = filtered_df[
593
- (filtered_df['year'] >= year_range[0]) &
594
- (filtered_df['year'] <= year_range[1])
595
- ]
596
-
597
- if filter_new:
598
- filtered_df = filtered_df[filtered_df['is_new']]
599
-
600
- if filter_2024:
601
- filtered_df = filtered_df[filtered_df['is_2024']]
602
-
603
- if search_term:
604
- filtered_df = filtered_df[
605
- filtered_df['title'].str.contains(search_term, case=False, na=False) |
606
- filtered_df['code'].str.contains(search_term, case=False, na=False)
607
- ]
608
-
609
- # Graphiques
610
- tab1, tab2, tab3 = st.tabs(["📋 Documents", "📊 Analyses", "💾 Export"])
611
-
612
- with tab1:
613
- st.header(f"📋 Documents ({len(filtered_df)} résultats)")
614
-
615
- if not filtered_df.empty:
616
- # Trier par année décroissante puis par code
617
- filtered_df = filtered_df.sort_values(['year', 'code'], ascending=[False, True])
618
-
619
- for _, doc in filtered_df.iterrows():
620
- with st.container(border=True): # Ajout d'une bordure pour chaque document
621
- col1, col2 = st.columns([4, 1])
622
-
623
- with col1:
624
- # Badges - Améliorés
625
- badges_html = f"<strong>{doc['code']}</strong> "
626
- if doc['is_new']:
627
- badges_html += "<span style='background-color: #90EE90; color: black; padding: 2px 6px; border-radius: 4px; font-size: 0.8em;'>NOUVEAU</span> "
628
- if doc['is_2024']:
629
- badges_html += "<span style='background-color: #ADD8E6; color: black; padding: 2px 6px; border-radius: 4px; font-size: 0.8em;'>2024</span> "
630
- badges_html += f"<span style='background-color: #D3D3D3; color: black; padding: 2px 6px; border-radius: 4px; font-size: 0.8em;'>{doc['category_name']}</span>"
631
- st.markdown(badges_html, unsafe_allow_html=True)
632
-
633
- st.markdown(f"**{doc['title']}**")
634
- st.caption(f"🏢 {doc['committee']} • 📅 {doc['year']}")
635
-
636
- with col2:
637
- # Boutons d'action - Liens HTML pour un meilleur contrôle
638
- st.markdown(f"[📄 Voir Document]({doc['pdf_url']}){{target='_blank'}}", unsafe_allow_html=True)
639
- st.markdown(f"[🔗 Voir Section]({doc['source_url']}){{target='_blank'}}", unsafe_allow_html=True)
640
-
641
- # st.divider() # Supprimé car le container a déjà une bordure
642
- else:
643
- st.info("Aucun document ne correspond aux critères sélectionnés")
644
-
645
- with tab2:
646
- st.header("📊 Analyses des Documents")
647
-
648
- if not df.empty:
649
- # Répartition par catégorie
650
- col1, col2 = st.columns(2)
651
-
652
- with col1:
653
- category_counts = df['category_name'].value_counts()
654
- fig1 = px.pie(
655
- values=category_counts.values,
656
- names=category_counts.index,
657
- title="Répartition par Catégorie"
658
- )
659
- st.plotly_chart(fig1, use_container_width=True)
660
-
661
- with col2:
662
- # Top 10 des comités les plus actifs
663
- committee_counts = df['committee'].value_counts().head(10)
664
- fig2 = px.bar(
665
- x=committee_counts.values,
666
- y=committee_counts.index,
667
- orientation='h',
668
- title="Top 10 Comités les Plus Actifs"
669
- )
670
- fig2.update_layout(yaxis={'categoryorder': 'total ascending'})
671
- st.plotly_chart(fig2, use_container_width=True)
672
-
673
- # Évolution temporelle
674
- year_counts = df.groupby(['year', 'category_name']).size().reset_index(name='count')
675
- fig3 = px.line(
676
- year_counts,
677
- x='year',
678
- y='count',
679
- color='category_name',
680
- title="Évolution des Documents par Année"
681
- )
682
- st.plotly_chart(fig3, use_container_width=True)
683
-
684
- # Documents récents
685
- st.subheader("🆕 Documents Récents (2023-2024)")
686
- recent_docs = df[df['is_new']].groupby(['year', 'category_name']).size().reset_index(name='count')
687
- if not recent_docs.empty:
688
- fig4 = px.bar(
689
- recent_docs,
690
- x='year',
691
- y='count',
692
- color='category_name',
693
- title="Nouveaux Documents par Année"
694
- )
695
- st.plotly_chart(fig4, use_container_width=True)
696
-
697
- # Analyse par comité
698
- st.subheader("📊 Analyse Détaillée par Comité")
699
- committee_analysis = df.groupby('committee').agg({
700
- 'code': 'count',
701
- 'is_new': 'sum',
702
- 'is_2024': 'sum'
703
- }).rename(columns={
704
- 'code': 'Total',
705
- 'is_new': 'Nouveaux',
706
- 'is_2024': 'Mis à jour 2024'
707
- }).sort_values('Total', ascending=False)
708
-
709
- st.dataframe(committee_analysis, use_container_width=True)
710
-
711
- with tab3:
712
- st.header("💾 Export des Données")
713
-
714
- col1, col2 = st.columns(2)
715
-
716
- with col1:
717
- # Export CSV
718
- csv = filtered_df.to_csv(index=False)
719
- st.download_button(
720
- label="📄 Télécharger CSV",
721
- data=csv,
722
- file_name=f"codex_documents_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
723
- mime="text/csv"
724
- )
725
-
726
- with col2:
727
- # Export JSON
728
- json_data = filtered_df.to_json(orient='records', indent=2)
729
- st.download_button(
730
- label="📋 Télécharger JSON",
731
- data=json_data,
732
- file_name=f"codex_documents_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
733
- mime="application/json"
734
- )
735
-
736
- # Statistiques d'export
737
- st.subheader("📊 Statistiques d'Export")
738
- export_stats = {
739
- "Total documents": len(filtered_df),
740
- "Nouveaux documents (2023+)": len(filtered_df[filtered_df['is_new']]),
741
- "Documents 2024": len(filtered_df[filtered_df['is_2024']]),
742
- "Comités uniques": filtered_df['committee'].nunique(),
743
- "Catégories": list(filtered_df['category_name'].unique()),
744
- "Période couverte": f"{int(filtered_df['year'].min()) if not filtered_df.empty else 'N/A'} - {int(filtered_df['year'].max()) if not filtered_df.empty else 'N/A'}",
745
- "Date d'extraction": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
746
- }
747
-
748
- st.json(export_stats)
749
-
750
- # Aperçu des données filtrées
751
- st.subheader("👀 Aperçu des Données Filtrées")
752
- display_df = filtered_df[['code', 'title', 'committee', 'year', 'category_name']].head(20)
753
- st.dataframe(display_df, use_container_width=True)
754
-
755
- # Test des liens PDF
756
- if st.checkbox("🔍 Tester les liens PDF (peut prendre du temps)"):
757
- st.info("Test des liens PDF en cours... Cela peut prendre quelques minutes.")
758
-
759
- pdf_results = []
760
- progress_bar = st.progress(0)
761
- status_text = st.empty()
762
-
763
- test_sample = filtered_df.head(20) # Tester plus de documents
764
-
765
- for i, (_, doc) in enumerate(test_sample.iterrows()):
766
- status_text.text(f"Test du PDF {i+1}/{len(test_sample)}: {doc['code']}")
767
- pdf_available = check_pdf_availability_cached(doc['pdf_url']) # Utiliser la version mise en cache
768
- pdf_results.append({
769
- 'Code': doc['code'],
770
- 'Titre': doc['title'][:50] + "..." if len(doc['title']) > 50 else doc['title'],
771
- 'PDF Disponible': '✅' if pdf_available else '❌',
772
- 'Lien PDF': doc['pdf_url']
773
  })
774
- progress_bar.progress((i + 1) / len(test_sample))
 
775
 
776
- st.subheader("📋 Résultats du Test PDF")
777
- pdf_df = pd.DataFrame(pdf_results)
778
- st.dataframe(pdf_df[['Code', 'Titre', 'PDF Disponible', 'Lien PDF']], use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
 
780
- available_pdfs = sum(1 for r in pdf_results if '✅' in r['PDF Disponible'])
781
- st.metric("📊 PDFs Disponibles", f"{available_pdfs}/{len(pdf_results)}")
782
 
783
- if __name__ == "__main__":
784
- main()
 
 
 
1
+ # codex_simple_extractor.py
 
 
2
  import requests
 
 
 
 
 
 
3
  from bs4 import BeautifulSoup
4
+ import re
5
  import time
6
 
7
+ # URL de la page à scraper
8
+ url = "https://www.fao.org/fao-who-codexalimentarius/codex-texts/codes-of-practice/fr/"
 
 
 
 
 
9
 
10
+ # Entête pour simuler un navigateur
11
+ headers = {
12
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
+ print(f"Tentative d'extraction depuis: {url}")
 
 
 
 
 
 
 
 
16
 
17
+ try:
18
+ # 1. Récupérer la page web
19
+ response = requests.get(url, headers=headers, timeout=30)
20
+ response.raise_for_status() # Lève une exception si le statut est une erreur (4xx, 5xx)
21
+ print(f"Page récupérée avec succès. Statut: {response.status_code}")
 
 
22
 
23
+ # 2. Analyser le contenu HTML
24
+ soup = BeautifulSoup(response.content, 'html.parser')
25
+ print("Analyse HTML terminée.")
26
 
27
+ # 3. Trouver tous les tableaux
28
+ tables = soup.find_all('table')
29
+ print(f"Nombre de tableaux trouvés sur la page: {len(tables)}")
30
 
31
+ documents = []
32
+ seen_codes = set() # Pour éviter les doublons
 
 
 
 
 
 
 
 
33
 
34
+ if tables:
35
+ print("Analyse des tableaux pour trouver les documents...")
36
  # Parcourir chaque tableau
37
+ for i, table in enumerate(tables):
38
+ # print(f" Analyse du tableau {i+1}...")
39
  rows = table.find_all('tr')
40
  for row in rows:
 
41
  cells = row.find_all(['td', 'th']) # Inclure th au cas où
42
+ # Un document valide a généralement au moins 4 cellules
43
  if len(cells) >= 4:
44
  # Extraire le texte de chaque cellule
45
  cell_texts = [cell.get_text(strip=True) for cell in cells]
46
 
47
+ # Essayer de trouver un code Codex CXC dans la première cellule
48
  code_candidate = cell_texts[0] if cell_texts else ""
49
+ # Pattern pour CXC suivi d'un numéro (ex: CXC 80-2020, CXC 43R-1995)
50
+ code_match = re.match(r'^(CXC)\s*([\w\-R]*\d+(?:-\d+)?)$', code_candidate)
51
 
52
  if code_match:
53
  prefix = code_match.group(1)
54
  number_part = code_match.group(2)
55
  full_code = f"{prefix} {number_part}"
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  if full_code not in seen_codes:
58
  seen_codes.add(full_code)
59
+
60
+ # Extraire les autres informations
61
+ title = cell_texts[1] if len(cell_texts) > 1 else "Titre non trouvé"
62
+ committee = cell_texts[2] if len(cell_texts) > 2 else "COMITE"
63
+ year_str = cell_texts[3] if len(cell_texts) > 3 else ""
64
+ try:
65
+ year = int(year_str) if year_str.isdigit() else 0
66
+ except ValueError:
67
+ year = 0
68
+
69
  documents.append({
70
  'code': full_code,
71
  'title': title,
72
  'committee': committee,
73
+ 'year': year
 
 
 
 
 
 
 
74
  })
75
+ print(f"Extraction terminée. Documents trouvés via analyse de tableau: {len(documents)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  else:
77
+ print("Aucun tableau trouvé. Tentative d'extraction via le texte brut...")
78
+ # Méthode de secours: Parser le texte brut
79
+ text_content = soup.get_text()
80
+ # Pattern pour extraire les documents dans le texte brut (format | CODE | Titre | Comité | Année |)
81
+ pattern = r'\|\s*(CXC)\s*([\w\-R]*\d+(?:-\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([A-Z0-9]{2,15})\s*\|\s*(\d{4})'
82
+ matches = re.findall(pattern, text_content, re.DOTALL)
83
+
84
+ for match in matches:
85
+ prefix, number_part, title, committee, year_str = match
86
+ full_code = f"{prefix} {number_part}"
87
+ title = title.strip()
88
+ committee = committee.strip()
89
  try:
90
+ year = int(year_str.strip())
 
 
 
 
 
 
91
  except ValueError:
92
+ year = 0
93
+
94
+ if full_code not in seen_codes:
95
+ seen_codes.add(full_code)
96
+ documents.append({
97
+ 'code': full_code,
98
+ 'title': title,
99
+ 'committee': committee,
100
+ 'year': year
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  })
102
+ print(f"Extraction terminée. Documents trouvés via analyse de texte brut: {len(documents)}")
103
+
104
 
105
+ # 4. Afficher les résultats
106
+ if documents:
107
+ print("\n--- Documents Extraits (5 premiers) ---")
108
+ # Trier par année décroissante
109
+ documents.sort(key=lambda x: x['year'], reverse=True)
110
+ for doc in documents[:5]:
111
+ print(f" - {doc['code']} | {doc['title'][:50]}... | {doc['committee']} | {doc['year']}")
112
+ print(f"\n--- Nombre Total de Documents Extraits: {len(documents)} ---")
113
+
114
+ # Optionnel: Sauvegarder dans un fichier
115
+ # with open("codes_of_practice_simple.txt", "w", encoding='utf-8') as f:
116
+ # for doc in documents:
117
+ # f.write(f"{doc['code']} | {doc['title']} | {doc['committee']} | {doc['year']}\n")
118
+ # print("Résultats sauvegardés dans 'codes_of_practice_simple.txt'")
119
+
120
+ else:
121
+ print("\nAucun document n'a pu être extrait.")
122
+ # Afficher un échantillon du texte pour débogage
123
+ print("\n--- Échantillon du texte de la page (1000 premiers caractères) ---")
124
+ print(soup.get_text()[:1000])
125
+ print("--- Fin de l'échantillon ---")
126
 
 
 
127
 
128
+ except requests.exceptions.RequestException as e:
129
+ print(f"Erreur lors de la requête HTTP : {e}")
130
+ except Exception as e:
131
+ print(f"Une erreur inattendue s'est produite : {e}")