"""Reproduce the BnF 2025 translation-language analysis. Python 3.11+, pandas and openpyxl. No participant or private data. Run: python analysis.py --source bnf-books-2025.xlsx --out results Download the source from SOURCE_URL; no source workbook is redistributed here. The output unit is a bibliographic record, not a sale, new work or reader. """ import argparse import hashlib import json from pathlib import Path import pandas as pd SOURCE_URL = 'https://static.data.gouv.fr/resources/observatoire-du-depot-legal/20261019-084134/observatoire-du-depot-legal-2025-indicateurs-livres.xlsx' def analyze(source, out): df = pd.read_excel(source, sheet_name='Données Observatoire 2025') df.columns = df.columns.str.strip() assert len(df) == 67498 and df['NNB'].nunique() == len(df) assert set(df['Année de parution BNF']) == {25} translations = df[df['Code de traduction'].fillna('').str.strip().eq('1 - Traduction (avec ou sans texte original)')].copy() translations['original_label'] = translations['Langue originale'].fillna('').str.strip().replace('', '[non renseigné]') total = len(translations) assert total == 14394 rows = [] for label, group in translations.groupby('original_label'): rows.append({'original_language_label': label, 'translation_records': len(group), 'share_all_translation_records_pct': 100 * len(group) / total, 'fiction_records': int(group['Genre de la publication'].eq('Fiction').sum()), 'documentary_records': int(group['Genre de la publication'].eq('Documentaire').sum()), 'french_publication_records': int(group['Langue de publication'].eq('français').sum())}) result = pd.DataFrame(rows).sort_values(['translation_records','original_language_label'],ascending=[False,True]) assert result.translation_records.sum() == total genres = [] for label in ['Fiction','Documentaire']: g = translations[translations['Genre de la publication'].eq(label)] count = int(g.original_label.isin(['anglais','japonais']).sum()) genres.append({'genre':label,'translation_records':len(g),'english_japanese_records':count,'english_japanese_share_pct':100*count/len(g)}) top2 = int(translations.original_label.isin(['anglais','japonais']).sum()) french = translations[translations['Langue de publication'].eq('français')] french_top2 = int(french.original_label.isin(['anglais','japonais']).sum()) summary = {'source_url':SOURCE_URL,'source_sha256':hashlib.sha256(Path(source).read_bytes()).hexdigest(), 'all_bibliographic_records':len(df),'translation_records':total, 'known_original_label_records':int(translations['Langue originale'].notna().sum()), 'missing_original_label_records':int(translations['Langue originale'].isna().sum()), 'distinct_nonempty_original_labels':int(translations['Langue originale'].nunique()), 'english_japanese_records':top2,'english_japanese_share_pct':100*top2/total, 'french_publication_translation_records':len(french),'french_publication_top2_records':french_top2, 'french_publication_top2_share_pct':100*french_top2/len(french), 'genres':genres, 'genre_gap_percentage_points':genres[0]['english_japanese_share_pct']-genres[1]['english_japanese_share_pct'], 'scope':'Records published in Bibliographie nationale française – Livres in 2025, translation code 1; all publication languages. Not deposits received in 2025, new titles, all French-language translations, sales or unique works.'} out.mkdir(parents=True,exist_ok=True) result.to_csv(out/'results.csv',index=False,encoding='utf-8') pd.DataFrame(genres).to_csv(out/'genre-comparison.csv',index=False,encoding='utf-8') (out/'results-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') return summary if __name__ == '__main__': p=argparse.ArgumentParser();p.add_argument('--source',required=True,type=Path);p.add_argument('--out',default='results',type=Path) args=p.parse_args(); print(json.dumps(analyze(args.source,args.out),ensure_ascii=False,indent=2))