lucidiot's cybrecluster

Traitement de dates chinoises dans jq

Lucidiot Informatique 2021-03-21
Documentation de quelques lignes de code liées à deux des pires ennemis de l'informatique : les alphabets non-latins et les dates.


La majorité de mon activité récemment a été axée sur mon projet ITSB, et ça va donner lieu à une série d'articles un peu techniques. Cet article-là est traduit d'un des tous premiers articles de mon nouveau site statique sur envs.net.

L'organisme responsable des enquêtes techniques d'accidents et incidents aériens pour l'aviation civile de Hong Kong s'appelle l'AAIA, ou Air Accidents Investigation Authority. Leur site fait partie de cette catégorie de sites asiatiques qui sont restés au Web 1.0, et c'est parfait comme ça : c'est plus léger donc plus rapide, plus accessible, plus lisible, et surtout c'est plus facile à traiter pour mon projet. Cela dit, je me suis heurté à un petit problème : le format des dates.

Les dates sont régulièrement un problème quand je fabrique mes flux RSS à partir de pages web, et je suis bien content que la balise <pubDate> soit optionnelle dans toutes les versions de la spécification RSS. Souvent, elles ne sont pas indiquées séparément des rapports, donc je me retrouve à appliquer des expressions régulières pour les chercher dans les titres, mais ça ne fonctionne que pour des titres qui sont un minimum consistants. Et quand je dépasse les capacités des fonctions de traitement de dates de jq, il faut rajouter une couche d'expressions régulières ou d'autres traitements bizarres.

Dans la version anglaise du site de l'AAIA, les dates sont dans un format plutôt standard : 28 April 2020, qui se traite assez facilement dans jq:

λ echo '"28 April 2020"' | jq 'strptime("%d %B %Y") | todateiso8601'
"2020-04-28T00:00:00Z"

Mais dans les versions en chinois traditionnel ou simplifié, et bien… c'est du chinois : la même date s'écrit 二零二零年四月二十八日. J'avais déjà auparavant rencontré des dates à un format encore compréhensible, comme 2020年04月28日, mais là, il va me falloir apprendre le chinois.

Formats de date chinois

J'ai demandé de l'aide à m455, un ami qui parle couramment chinois, et j'ai eu un cours express de dates en chinois :

Caractère Définition
0
0
1
2
3
4
5
6
7
8
9
10
二十 20
三十 30
Year
Month
Day

Les années sont toujours exprimées chiffre par chiffre ("deux zéro deux zéro" et pas "deux mille vingt"), donc seules les dizaines et les unités m'intéressent. En prenant ce tableau et l'exemple précédent 二零二零年四月二十八日, on en déduit "2020 (année) 4 (mois) 2 10 8 (jour)". Le "2 10 8" devient donc 28.

Ce format de date sans chiffres arabes est aujourd'hui utilisé seulement dans certaines régions, dont Hong Kong. On a aujourd'hui tendance à préférer le format simplifié 2020年04月28日 ou même l'ISO 8601 2020-04-28, même dans un texte en chinois traditionnel.

Implémentation

J'ai déjà un fichier helpers.jq qui contient des fonctions que j'utilise couramment dans mes scripts. J'y ai donc rajouté une fonction parse_chinese_date, qui reçoit en entrée une date qui peut être dans un des deux formats qui contiennent des caractères chinois. La fonction s'occupe de découper en année, mois et jour, et envoie chaque nombre à parse_chinese_number pour traiter chaque nombre.

Traitement des nombres

J'ai commencé par une fonction qui ne fonctionnerait qu'avec les unités :

def parse_chinese_number:
    . as $input
    | {"零": "0", "〇": "0", "一": "1", "二": "2", "三": "3", "四": "4",
       "五": "5", "六": "6", "七": "7", "八": "8", "九": "9"} as $charmap
    | $input / ""
    | map($charmap[.])
    | join("")
    | tonumber;

Notez le bricolage étrange entre . $input et $charmap. Dans jq, on ne peut pas définir de variables en dehors du contenu d'une fonction ou du script principal, et le point-virgule ne peut pas séparer deux instructions. Quand je définis $charmap puis que j'utilise l'opérateur |, $charmap devient le contexte actuel (le .), donc je perds l'autre contexte qui était la chaîne de caractères passée à la fonction.

Pour gérer les dizaines, vu que je travaille caractère par caractère, je me suis dit que commencer par juste ignorer 十 en ajoutant "十": "" dans ma table pourrait être une bonne idée : 二十八 devient alors traité comme 二八 (2 et 8), donc on obtient aussi 28. Mais ça ne fonctionne certainement pas dans tous les cas :

Nombre Nombre traité Résultat attendu Résultat obtenu
二十八 二八 28 28
十八 18 8
二十 20 2
"" 10 Type error

La "Type error" se produit au niveau de tonumber, qui a besoin d'un nombre pour faire un nombre. J'ai donc géré ces trois cas particuliers comme ceci :

J'ai également ajouté la possibilité de donner des chiffres arabes à cette fonction, qui seront totalement ignorés, avec map($charmap[.] // .). L'opérateur // permet de donner une valeur par défaut à la place d'un null, donc quand un caractère n'existe pas dans ma table, on renvoie juste ce caractère sans le traiter.

La fonction devient alors :

def parse_chinese_number:
    . as $input
    | {"零": "0", "〇": "0", "一": "1", "二": "2", "三": "3", "四": "4",
       "五": "5", "六": "6", "七": "7", "八": "8", "九": "9", "十": ""} as $charmap
    | $input / ""
    | map($charmap[.] // .)
    | join("")
    # Gestion du cas de 十 seul
    | if . == "" then 1 else . end
    | tonumber
    | 
      # Gestion des nombres multiples de 10
      if $input|endswith("十") then . * 10
      # Gestion des nombres de 11 à 19, s'ils sont écrits avec 十
      elif $input|startswith("十") then . + 10
      # Dans jq, une clause else est obligatoire dans un if.
      else . end;

Et on peut tester à nouveau :

λ echo '["二十八", "十八", "二十", "十"]' | jq 'import "./helpers" as helpers; map(helpers::parse_chinese_number)'
[28, 18, 20, 10]

Traitement de la date

Il ne me reste plus qu'à découper la date et utiliser ma fonction de traitement de nombres dessus. Pour cela, une bonne vieille expression régulière !

Je veux isoler les trois nombres en utilisant les trois caractères séparant chaque morceau de la date, <year>年<month>月<day>日. Je veux permettre les chiffres arabes et les caractères chinois, donc je cherche des répétitions de [0123456789零〇一二三四五六七八九十]. Je veux permettre n'importe quel nombre de caractères parce que je veux gérer n'importe quelle année, et parce que jq plantera plus tard de toute façon. J'utilise donc la fonction capture() de jq pour collecter mes trois nombres :

λ echo '二零二零年四月二十八日' | jq -R 'capture("(?<year>[0123456789零〇一二三四五六七八九十]+)年(?<month>[0123456789零〇一二三四五六七八九十]+)月(?<day>[0123456789零〇一二三四五六七八九十]+)日")'
{
  "year": "二零二零",
  "month": "四",
  "day": "二十八"
}

Je peux ensuite utiliser map_values(parse_chinese_number) pour interpréter tous les nombres ; toutes les valeurs de l'objet JSON que j'ai récupéré.

Il ne me reste ensuite plus qu'à en faire un timestamp Unix. Je peux le faire de beaucoup de manières, et celle qui m'a semblé la plus rapide à faire quand j'ai écrit la fonction est de transformer ça en une bonne vieille date ISO 8601, puis de la convertir en timestamp : "\(.year)-\(.month)-\(.day)T00:00:00Z" | fromdateiso8601.

Mais avant de faire cette conversion, j'ai ajouté une vérification sur l'année pour être en mesure de gérer le calendrier chinois ; certains organismes comme le Taiwan Transportation Safety Board utilisent des années qui sont décalées de 1911 ans avec le calendrier grégorien. Contrairement au calendrier chinois, ils utilisent cependant les jours et mois du calendrier grégorien et pas un calendrier lunaire, donc on a des dates comme 105-05-21 qui correspond au 21 mai 2016. J'ajoute donc 1911 ans à une année si elle est inférieure à 1900 ; le plus ancien rapport d'enquête dans tous mes flux RSS date des années 50, et le plus ancien pour ces organismes asiatiques est de la fin des années 90. Ça impliquera que les dates basées sur les années du calendrier chinois ne seront plus correctement interprétées à partir de l'an 3811, et je crois que d'ici là, cet article relèvera d'archives préhistoriques.

On obtient donc cette dernière fonction :

def parse_chinese_date:
    capture("(?<year>[0123456789零〇一二三四五六七八九十]+)年(?<month>[0123456789零〇一二三四五六七八九十]+)月(?<day>[0123456789零〇一二三四五六七八九十]+)日")
    | map_values(parse_chinese_number)
    # Gestion des années du calendrier chinois
    | if .year < 1900 then .year += 1911 else . end
    | "\(.year)-\(.month)-\(.day)T00:00:00Z"
    | fromdateiso8601;

On peut rapidement la tester :

λ echo '二零二零年四月二十八日' | jq -R 'import "./helpers" as helpers; helpers::parse_chinese_date | todateiso8601'
"2020-04-28T00:00:00Z"

Utilisation

J'ai ensuite pu utiliser cette fonction de traitement dans le script qui crée des flux RSS pour l'AAIA. Cela a ajouté une douzaine de flux d'un seul coup, et ce nombre pourrait prochainement passer à 21 avec quelques améliorations prévues. Je n'ai pour l'instant pas d'autre source qui utilise des dates chinoises complexes à traiter, mais il n'est pas impossible que j'en aie d'autres, par exemple si je trouve qui enquête sur les accidents ferroviaires à Hong Kong.

Peut-être que je distribuerai plus tard cet interpréteur de dates en tant que package jqnpm


Commentaires

Il n'y a pour l'instant aucun commentaire. Soyez le premier !