Monorailcat

WhatsApp statistics (1)

icon 19/11/2023 - No comments

Some people requested this specific article to be written in English.

Back a few years ago, I got bored during Covid and realized it could be entertaining to get statistics out of a WhatsApp group. So things started with a bash script using sed, grep and wc, to count messages and occurrences of some words.
It looked like some people in that group had habits of texting during long meetings at work or just before lunch, and that some others seem to swear a lot. Running the log through a script can't lie.

After that, someone else mentioned the Pandas library for Python, that it can be handy to manipulate big amounts of data without iterating through like it's usually done with arrays and dictionaries. They also mentioned a couple of killer-features like loading/saving CSV files in one line, being able to manipulate timestamps, or being able to plot its dataframes in a single-line using Matplotlib. So why not rewrite that script from scratch using Pandas?

Reading log files
First, we need to look at the file saved by Whatsapp. It's 8845 lines long, humans can't process that easily, but the first lines give a good idea of the syntax and pattern:

01/02/2018, 12:34 - Messages and calls are end-to-end encrypted. No one outside of this chat, not even WhatsApp, can read or listen to them. Tap to learn more.
01/02/2018, 12:34 - ABC created group "XYZ"
01/02/2018, 12:34 - ABC added you
01/02/2018, 12:34 - ABC: <Media omitted>
01/02/2018, 12:34 - ABC: First blood!
01/02/2018, 12:35 - ABC: I dont have DEF's number

How convenient, the 6 first lines show all we need in order to understand the syntax:
DD/MM/YYYY, HH:MM - Name: Message content

We can ignore any line not following this pattern since people being added, leaving, or changing phone numbers don't bring any relevant information.
It seems that the way timestamps are recorded in the logs differ depending on the locales used by the phone. In this case, they are set to English/Germany.

Now we roughly understand how messages are sorted, we can read the file and format it in an usable way.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import pandas as pd
import re
FILE = './WhatsApp Chat with XYZ.txt'

def read_file(filename):
	# basic text file read
	f = open(filename, 'r')
	text = f.read()
	f.close()

	# Regex to account for messages taking up multiple lines and multiple posts
	pattern = re.compile(r'^(\d\d\/\d\d\/\d\d\d\d.*?)(?=^^\d\d\/\d\d\/\d\d\d\d|\Z)', re.S | re.M)
	
	# Sort everything by timestamp, sender and message
	timestamp = []
	sender = []
	message = []
	data = [m.group(1).strip().replace('\n', ' ') for m in pattern.finditer(text)]
	# replace image string to make it easier to sort
	data = [line.replace('<Media omitted>', '<Media_omitted>') for line in data]
	# replace multiple ? or ! to make them easier to count
	data = [re.sub(r'[\?\.\!]+(?=[\?\.\!])', '', line) for line in data]

	for row in data:
		# timestamp is before the first dash
		timestamp.append(row.split(' - ')[0])
		# sender is between dash and colon
		try:
			s = re.search('\ - (.*?):', row).group(1)
			sender.append(s)
		except:
			sender.append('')
		# message content is after the first colon
		try:
			message.append(row.split(': ', 1)[1])
		except:
			message.append('')
	# concatenating all three string arrays into a Pandas DataFrame
	df = pd.DataFrame(zip(timestamp, sender, message), columns=['timestamp', 'sender', 'message'])
	# converts timestamp string into a timestamp object
	df['timestamp'] = pd.to_datetime(df.timestamp, format='%d/%m/%Y, %H:%M')

	# removes events not associated with a sender
	df = df[df.sender != ''].reset_index(drop=True)
	return df
	
if __name__ == "__main__":
	df = read_file(FILE)
	print df

The result matches exactly the beginning of the file we fed to the script:
    timestamp              sender    message
0   2018-02-01 12:34:00    ABC       <Media_omitted>
1   2018-02-01 12:34:00    ABC       First blood!
2   2018-02-01 12:35:00    ABC       I dont have DEF's number
[8325 rows x 3 columns]
The first thing to see is that the number of rows doesn't match the number of lines of the text file, since lines corresponding to events rather than messages have been dropped.
Now we have our data in an usable format, so let's continue playing by extracting data.

Basic data extraction
What data can be useful at this stage?
  • date and time
  • Number of messages, but this can be misleading since message length seems to vary
  • Number of words, seems to be a more realistic index
  • Number of messages containing question marks, exclamation marks, links or images, to give a rough idea of the content of the messages
  • Number of words by message, to check if the number of messages and number of words correlate

Here's how the corresponding code looks like:
def expand_frame(df):
	# number of messages vs time (absolute date)
	df['date'] = pd.to_datetime(df['timestamp']).dt.strftime('%Y-%m-%d')
	# number of messages vs day of the week
	df['DoW'] = pd.to_datetime(df['timestamp']).dt.day_name()
	# number of messages vs time of the day, hh:mm and hour
	df['time'] = pd.to_datetime(df['timestamp']).dt.time
	df['timeh'] = pd.to_datetime(df['timestamp']).dt.hour
	# flag a message that contains images, exclamation/question marks or links
	df['q'] = df['message'].str.count('\?')
	df['x'] = df['message'].str.count('!')
	df['link'] = df['message'].str.contains('http')
	df['img'] = df['message'] == '<Media_omitted>'
	# count words for each message
	df['wordcount'] = df.message.apply(lambda x: len(x.split()))
	
	return df

It seems to be doing the things we want:
               timestamp  sender                        message
0    2018-02-01 12:34:00    ABC                         <Media_omitted>
1    2018-02-01 12:34:00    ABC                         First blood!
2    2018-02-01 12:35:00    ABC                         I dont have DEF's number
[8325 rows x 12 columns]

timestamp sender message date DoW time timeh q x link img wordcount
0 2018-02-01 12:34:00 ABC 2018-02-01 Friday 12:34:00 12 0 0 0 1 1
1 2018-02-01 12:34:00 ABC First blood! 2018-02-01 Friday 12:34:00 12 0 1 0 0 2
2 2018-02-01 12:35:00 ABC I dont have DEF's number 2018-02-01 Friday 12:34:00 12 0 0 0 0 5
[8325 rows x 12 columns]
[/code] We'll come back to it later when we need more data to process.

Sorting data
The next step is to analyze the data and put it into human-readable formats.
We'll start to group this data by sender and by day of the week
DOW = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
DOW2 = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
def sort_frames(df):
	# data by sender
	dfsender = pd.DataFrame(zip(
		sorted(df.groupby(['sender'])['sender'].indices.keys()), # list of senders (groupby sorts by alphabetic order)
		df.groupby(['sender'])['message'].count(), # counts number of messages by sender
		df.groupby(['sender'])['img'].sum(), # counts number of images by sender
		df.groupby(['sender'])['q'].sum(),
		df.groupby(['sender'])['x'].sum(),
		df.groupby(['sender'])['link'].sum(),
		df.groupby(['sender'])['wordcount'].sum() # number of words by sender
		),
		columns = ['sender', 'messagecount', 'img', 'q', 'x', 'link', 'wordcount']
	)
	# computes average message length
	dfsender['length'] = dfsender['wordcount'] / dfsender['messagecount']
	
	# data vs timestamp
	dfdate = pd.DataFrame(zip(
		sorted(df.groupby(['date'])['date'].indices.keys()),
		df.groupby(['date'])['message'].count(),
		df.groupby(['date'])['img'].sum(),
		df.groupby(['date'])['q'].sum(),
		df.groupby(['date'])['x'].sum(),
		df.groupby(['date'])['link'].sum(),
		df.groupby(['date'])['wordcount'].sum()
		),
		columns=['date', 'messagecount', 'img', 'q', 'x', 'link', 'wordcount'],
	)
	dfdate['length'] = dfdate['wordcount'] / dfdate['messagecount']

	# filling gaps in index
	dfdate['date'] = pd.to_datetime(dfdate['date'])
	dfdate.set_index('date', inplace=True)
	dfdate = dfdate.resample('1D').mean()
	dfdate.reset_index(inplace=True)
	dfdate.fillna(0, inplace=True)
	
	return dfsender, dfdate
This way we can display the results:
   sender  messagecount    img    q   x   link  wordcount    length
0   AAAAA           681   20.0   45   3   24.0       4401  6.462555
1    BBBB           194   40.0   19  35    2.0        937  4.829897
2    CCCC          1489  297.0  224  86   78.0       7240  4.862324
3    DDDD           179    3.0   22   3    0.0        658  3.675978
4  EEEEEE           512   38.0  139  21    8.0       3119  6.091797
5    FFFF           803  121.0  146  33   88.0       4472  5.569116
6   GGGGG          2491  119.0  365  39   83.0      14876  5.971899
7  HHHHHH           131   12.0   25   4    2.0        720  5.496183
8  IIIIII          1845  300.0  341  74  106.0      11975  6.490515

            messagecount  img     q    x  link  wordcount    length
date                                                               
2018-02-01           4.0  1.0   0.0  1.0   0.0       26.0  6.500000
2018-02-02          10.0  0.0   3.0  0.0   0.0       80.0  8.000000
2018-02-03           5.0  2.0   1.0  0.0   0.0       25.0  5.000000
2018-02-04           0.0  0.0   0.0  0.0   0.0        0.0  0.000000

Plotting data
This is not very convenient to read, we can plot it instead.
import matplotlib.pyplot as plt
plt.rcParams["figure.figsize"] = [10.24, 6] # sets plots resolution to 1024*600px

def subplotr(df, axes, index=0):
	if(len(df) < 30): # avoids too dense bar plots
		plkind='bar'
	else:
		plkind='line'
	if(index == 0):
		pass
	else:  #selects X axis
		df.set_index(index, inplace=True)
	df['messagecount'].plot(ax=axes[0, 0], kind=plkind, label='messages')
	df['wordcount'].plot(ax=axes[0, 1], kind=plkind, label='words')
	df[['img', 'q', 'x', 'link']].plot(ax=axes[1, 0], kind=plkind)
	df['length'].plot(ax=axes[1, 1], kind=plkind, label='length')
	axes[0, 0].set_ylabel('Messages')
	axes[0, 1].set_ylabel('Words')
	axes[1, 0].set_ylabel('Counts')
	axes[1, 1].set_ylabel('Length')
	for i in range(2):
		for j in range(2):
			axes[i, j].margins(0)

def generate_plots():
	#fig1 - everything by sender
	fig1, axes1 = plt.subplots(2, 2)
	fig1.suptitle('Data by sender')
	subplotr(dfsender, axes1, 'sender')
	# ...
	#fig3 - everything vs time
	fig3, axes3 = plt.subplots(2, 2)
	fig3.suptitle('Data vs time')
	subplotr(dfdate, axes3, 'date')
	
plt.show()

Data by sender
Data vs time
It looks usable, but we can still do better.

Basic statstics
We can actually extract the peaks and plot them:
def top(dfdate):
	## top 10
	dfdate.set_index('date', inplace=True)
	dftop = pd.concat( # normalized data
		[dfdate.sort_values('messagecount', ascending=False).groupby('date')['messagecount'].head()[:10] / dfdate.sort_values('messagecount', ascending=False).groupby('date')['messagecount'].head()[0],
		dfdate.sort_values('wordcount', ascending=False).groupby('date')['wordcount'].head()[:10] / dfdate.sort_values('wordcount', ascending=False).groupby('date')['wordcount'].head()[0],
		dfdate.sort_values('img', ascending=False).groupby('date')['img'].head()[:10] / dfdate.sort_values('img', ascending=False).groupby('date')['img'].head()[0],
		dfdate.sort_values('link', ascending=False).groupby('date')['link'].head()[:10] / dfdate.sort_values('link', ascending=False).groupby('date')['link'].head()[0],
		dfdate.sort_values('length', ascending=False).groupby('date')['length'].head()[:10] / dfdate.sort_values('length', ascending=False).groupby('date')['length'].head()[0],
		], sort=True, axis=1)
	dfdate.reset_index(inplace=True)
	dftop.fillna(0, inplace=True)
	return dftop
	
def generate_plots():
	# ...
	#fig 10 - Top 10 plots
	fig10, axes10 = plt.subplots()
	axes10.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
	axes10.bar(dftop.index, dftop['messagecount'], label='message', width=10, alpha=0.5)
	axes10.bar(dftop.index, dftop['wordcount'], label='words', width=10, alpha=0.5)
	axes10.bar(dftop.index, dftop['img'], label='images', width=10, alpha=0.5)
	axes10.bar(dftop.index, dftop['link'], label='links', width=10, alpha=0.5)
	axes10.bar(dftop.index, dftop['length'], label='length', width=10, alpha=0.5)
	axes10.legend()
	fig10.suptitle('Top 10 dates (normalized)')

We generate a DataFrame containing all the top ten values from each field, divided by its maximum value to make them readable on the same Y-axis. An alternative would be to use a log scale on the Y-axis.

At this point, we start to have a lots of plots to display, and plt.show() starts making a mess by opening too many windows.
We can comment plt.show() and replace it by the following line, duplicated for each figure we want:
fig10.savefig('Figure_10.png')
This way plots are silently exported to files.

Top dates

Understanding data
The interesting thing about the plots showing data over time is to look at the log around the dates showing-up to see what happened.
  • November 2018, peak number of images, discussing and exchanging memes
  • Februrary 2019, unofficial ski event, log also shows lots of questions similar to: "I'm here, where are you guys?"
  • 2020, Covid and lockdowns, low activity
  • February 2021, something major happened to the company
  • May 2022, someone mentioned he might leave the team
Peaks in message length are jokes or copy/pasted text

Going further
The code took roughly one rainy weekend to get to the state where things were working well enough to get usable data, and a few weeks to improve the code based on Pandas' learning curve.
It's a good start to play with Pandas, but we can do more.

Notes and references

icon Tags de l'article : , ,

Horloge CSS Javascript

icon 09/10/2022 - No comments

Horloge CSS Javascript
J'ai voulu faire un projet simple faisable en une après-midi pluvieuse.
Ça fait quelques temps que je me disais que je ne savais pas vraiment faire de design web, et que je me contentais de chercher dans Stackoverflow pour des choses assez simples.

Partons sur une copie de la célèbre horloge des SBB-CFF avec les caractéristiques suivantes :
  • Cadran blanc avec 12 graduations épaisses pour les heures et 48 graduations fines pour les minutes
  • Aiguille des heures qui tourne de 0.5° par minute, de façon discrète
  • Aiguille des minutes qui tourne de 6° par minute, de façon discrète
  • Aiguille des secondes qui tourne de 6° par seconde, de façon discrète
  • Bulbe ajouté à l'extrémité de l'aiguille des secondes, pour la distinguer facilement
Horloge d'un quai de la gare de Genève-Cornavin
Les vraies horloges fonctionnent différemment avec l'aiguille des secondes entrainée par un moteur synchrone, qui fait un tour en 58.5s, puis s'arrête 1.5s sur la graduation 0, avant qu'une impulsion venant d'une horloge maître fasse avancer l'aiguille des minutes de 6°, l'aiguille des heures de 0.5°, et fasse continuer l'aiguille des secondes. Cette fonctionnalité n'a pas été reprise.
Ici, tout est fait de façon vectorielle, sans utiliser d'image.
Horloge SBB finale
Structure
Commençons par la structure de la page en séparant le code CSS, HTML et Javascript :
<style>
	/* Code CSS permettant la mise en page des blocs définis plus bas */
</style>

<body>
	<div class="clock">
		<!-- Code HTML définissant tous les blocs utilisés pour afficher une horloge -->
	</div>
</body>

<script>
	// Code Javascript permettant d'animer les aiguilles de l'horloge
</script>

Hiérarchie
On va devoir définir tous les éléments de façon hiérarchique, par classe :
Clock |- Circle - Quarter*4 - Hour*3 - Minute*4
       |- Hands
Ce qui nous donne 4 quartiers, 12 heures et 48 minutes. Les 12 minutes manquantes sont multiples de 5 et confondues avec les graduations des heures.

Code HTML
C'est assez simple, on va définir les éléments choisis dans la hiérarchie, avec pas mal de copier-coller.
<div class="clock">
<div class="circle">
	<div class="quarter1">
		<div class="hour12">
			<div class="minute1"></div>
			<div class="minute2"></div>
			<div class="minute3"></div>
			<div class="minute4"></div>
		</div>
		<div class="hour1">
			<div class="minute1"></div>
			<div class="minute2"></div>
			<div class="minute3"></div>
			<div class="minute4"></div>
		</div>
		<div class="hour2">
			<!-- ... -->
		</div>
	</div>
	<div class="quarter2">
		<!-- ... -->
	</div>
	<div class="quarter3">
		<!-- ... -->
	</div>
	<div class="quarter4">
		<!-- ... -->
	</div>
	<div class="hourshand"></div>
	<div class="minuteshand"></div>
	<div class="secondshand"></div>
	<div class="secondsbulb"></div>
</div>
Le code a été tronqué pour le rendre lisible.
Ce code seul n'affiche rien. les balises "div" définissent un bloc vide, mais permettent de lui assigner une classe ou un identifiant. On utilise des classes, mais on pourrait utiliser des identifiants si les blocs étaient uniques.

Code CSS
On va tenter de rendre les choses paramétriques en utilisant des variables dans le code CSS:
	:root {
		--size: 256px;							/* circle diameter */
		--hwidth: calc(var(--size) / 25);		/* hours marks 10px*/
		--hheight: calc(var(--size) / 8);		/* hours marks 32px*/
		--mwidth: calc(var(--size) / 85);		/* minutes marks 3px*/
		--mheight: calc(var(--size) / 32);		/* minutes marks 8px */
		--hhwidth: calc(var(--size) / 16);		/* hours hand 16px */
		--hmwidth: calc(3 * var(--size) / 64);	/* minutes hand 12px */
		--hswidth: calc(var(--size) / 64);		/* seconds hand 4px */
		--bradius: calc(3 * var(--size) / 32);	/* bulb radius 24px */
	}
Cadran
Puis on va coommencer par définir un disque blanc sur lequel on va afficher tous les éléments du cadran et les aiguilles :
	.circle {
		display: block;
		margin-left: auto;
		margin-right: auto;
		/*position: absolute;*/
		top: 0%;
		left: 0%;
		background-color: white;
		width: var(--size);
		height: var(--size);
		border-radius: 50%;
	}
Graduations
L'intérêt des classes est de pouvoir appliquer les mêmes paramètres à plusieurs éléments en fonction de leur hiérarchie.
.hour1,
	.hour2,
	.hour12 {
		position: absolute;
		top: calc(var(--hwidth) / 2);
		left: calc(50% - var(--hwidth) / 2);
		background-color: black;
		width: var(--hwidth);
		height: var(--hheight);
		transform-origin: calc(var(--hwidth) / 2) calc( (var(--size) - var(--hwidth) ) / 2);
	}
Horloge SBB heures
Ça donne 3 blocs noirs superposés en haut et au centre du disque. Rien d'impressionnant, sauf qu'on peut faire mieux :
.hour1 {transform: rotate(30deg);}
.hour2 {transform: rotate(60deg);}
Ce qui va laisser un bloc à 12h, et en bouger un à 1h et l'autre à 2h.
L'attribu "transform-origin" permet de spécifier que la transformation se fait à partir du centre de l'horloge, en prenant en compte la largeur des figures.
Horloge SBB heures

On fait exactement pareil avec les minutes :
	.minute1,
	.minute2,
	.minute3,
	.minute4 {
		position: absolute;
		top: 0;
		left: 0;
		background-color: black;
		width: var(--mwidth);
		height: var(--mheight);
		transform-origin: calc(var(--mwidth)) calc( var(--size) / 2 - 2 * var(--mwidth)) 122px;
	}
	.minute1 {transform: rotate(8deg);}
	.minute2 {transform: rotate(14deg);}
	.minute3 {transform: rotate(20deg);}
	.minute4 {transform: rotate(26deg);}
Et comme chaque bloc de 4 graduations de minutes est inclu dans une graduation d'heures, il est aussi dupliqué.
Horloge SBB minutes

Une fois qu'on a un quartier, on le copie :
	.quarter1,
	.quarter2,
	.quarter3,
	.quarter4 {transform-origin: 50% calc(var(--size) / 2);}
	.quarter1 {transform: rotate(360deg);}
	.quarter2 {transform: rotate(90deg);}
	.quarter3 {transform: rotate(180deg);}
	.quarter4 {transform: rotate(270deg);}

À partir de là, on a un cadran complet affichable.

Aiguilles
On va faire des blocs similaires aux graduations. On va aussi les faire tourner autour du centre, mais on va aussi les placer au centre :
	.hourshand {
		display: block;
		position: absolute;
		top: calc(3 * var(--size) / 16);
		left: calc(50% - var(--hhwidth) / 2);
		width: var(--hhwidth);
		height: calc(var(--size) / 2 - var(--hhwidth));
		background-color: black;
		transform-origin: calc(var(--hhwidth) / 2) calc(var(--size) / 2 - 3 * var(--size) / 16);
	}
	.minuteshand {
		display: block;
		position: absolute;
		top: var(--hmwidth);
		left: calc(50% - var(--hmwidth) / 2);
		width: var(--hmwidth);
		height: calc( (9/16) * var(--size));
		background-color: black;
		transform-origin: calc(var(--hmwidth) / 2) calc(var(--size) / 2 - var(--hmwidth));
	}
	.secondshand {
		display: block;
		position: absolute;
		top: calc(var(--size) / 8);
		left: calc(50% - var(--hswidth) / 2);
		width: var(--hswidth);
		height: calc(var(--size) / 2);
		background-color: #eb0000;
		transform-origin: calc(var(--hswidth) / 2) calc(var(--size) / 2 - var(--size) / 8);
	}
	.secondsbulb {
		display: block;
		position: absolute;
		top: var(--bradius);
		left: calc(50% - var(--bradius) / 2);
		width: var(--bradius);
		height: var(--bradius);
		background-color: #eb0000;
		border-radius: 50%;
		transform-origin: calc(var(--bradius) / 2) calc(var(--size) / 2 - var(--bradius));
	}
À cette étape-ici, on a une horloge complète, mais immobile.
Horloge SBB nojs

Code Javascript
On commence par définir une fonction qu'on va appeller à chaque seconde :
clock()

function clock() {
	// todo
}
setInterval(clock, 1000);
Ensuite, on va remplir cette fonction pour obtenir les valeurs correspondant à l'heure, minute et seconde actuelle :
  const date = new Date();
  const seconds = date.getSeconds();
  const minutes = date.getMinutes();
  //const hours = ((date.getHours() + 11) % 12 + 1); // use integers
  const hours = ((date.getHours() + 11) % 12 + 1) + minutes / 60.0; // use floats

L'heure est convertie de 24 à 12 heures, mais c'est facultatif, 11h et 23h ont le même angle à 360° près.
Il est aussi possible de choisir entre des heures discrètes, s'incrémentant par pas de 30 degrés toutes les heures, ou par pas de 0.5°, à chaque minute.

On va ensuite convertir ces valeurs en angles :
  const second = seconds * 6;
  const minute = minutes * 6;
  const hour = hours * 30;
Puis faire tourner les aiguilles d'un angle correspondant à la date :
  document.querySelector('.secondshand').style.transform = `rotate(${second}deg)`
  document.querySelector('.secondsbulb').style.transform = `rotate(${second}deg)`
  document.querySelector('.minuteshand').style.transform = `rotate(${minute}deg)`
  document.querySelector('.hourshand').style.transform = `rotate(${hour}deg)`

Toutes ces étapes nous donnent une horloge fonctionnelle.
Horloge SBB finale

icon Tags de l'article : , ,

Flux RSS/Atom : Traitement, mise-en-page

icon 13/04/2016 - No comments

J'utilise beaucoup de flux RSS, et j'aime bien que tout le contenu soit disponible, par exemple un article complet sans commentaires dans le cas d'un blog.

Le principe est de pouvoir l'utiliser offline, par exemple en chargeant la page. Ça permet aussi d'éviter quelques clics (dont les pubs et une mise en page non neutre) et d'avoir tout le contenu directement dans le lecteur RSS.

Bons Exemples
  • Hackaday
  • Linuxfr
  • Blogotext

Mauvais Exemples
  • EDN
  • Feedburner
  • La majorité des blogs Wordpress

EDN
Comme on peut remarquer, le flux : http://www.edn.com/rss/design/analog ne contient que des liens vers les pages des articles et une description un peu vide.

Une première étape est de changer le lien vers la version imprimable de l'article. Les deux URL ont l'identifiant de l'article en commun :
http://edn.com/design/analog/$ID_ARTICLE/TITRE_ARTICLE
http://www.edn.com/Home/PrintView?contentItemId=ID_ARTICLE

Une ligne de bash suffit à faire le travail de base :
wget -qO- http://www.edn.com/rss/design/analog |sed -e 's#<link><!\[CDATA\[http://edn.com/design/analog/#<link><!\[CDATA\[http://www.edn.com/Home/PrintView?contentItemId=#' |sed -e 's#/[^/]*\]\]></link>#\]\]></link>#'
L'étape suivant est d'utiliser ce lien pour remplir le flux.

Comme mon niveau en PHP tient plus du bricolage qu'autre chose, il est sûrement possible de faire beaucoup mieux et plus efficace.

On va utiliser un algo simple :
function do_process(temp_file) {
    articles[0][] = get_article_ids(temp_file);
    articles[1][] = get_article_titles(temp_file);
    articles[2][] = get_articles_dates(temp_file);
    
    rss = fopen("new_rss", 'w');
    fputs(rss, header);
    iter = 0;
    foreach(articles[0] as id) {
        article = download(article_url + "id");
        fputs(rss, article);
        fputs(rss, articles[1][iter]);
        fputs(rss, articles[2][iter]);
        iter++;
    }
    fputs(rss, footer);
    fclose(rss);
}

oldsum = md5sum(temp_file);
if((date() - date(temp_file)) > timestep {
    download(url, temp_file);
    if(md5sum(temp_file) != oldsum) {
        do_process(temp_file);
    }
}

Le script commence par tester si le flux RSS a été mis à jour récemment et s'il a changé, pour économiser la bande passante et les ressources du site distant comme ceux de la machine executant le script.
Ensuite, le flux RSS est analysé, les identifiants, date et titre de chaque article sont stockés dans un tableau qui est utilisé pour construire le flux RSS final.
La librairie simple_html_dom permet de manipuler facilement les balises XML.

Bugs
La date est incorrecte dans certains articles du flux RSS d'EDN (Nov 01 2016 pour un article écrit le 11 Jan 2016), mais la majorité fonctionne.

Références

icon Tags de l'article : , ,

Compaq Contura 4/25 : DOS, Windows

icon 22/03/2016 - No comments

Limitations
Le PC n'a qu'un seul disque dur (40Go à la place des 200Mo d'origine), un port parallèle, un seul port série (utilisé par une souris), et le lecteur de disquettes et le modem ne sont plus présents (HS). L'installation de programme va être difficile sans ruser.

On commence par brancher le disque sur un convertisseur USB-IDE pour formatter le disque, le tester et copier les fichiers de DOS, Windows et les drivers.
On peut aussi utiliser Laplink et kermit pour transférer des données avec un câble série ou parallèle.

Qemu
Qemu permet de créer les partitions, de formater le disque et de copier le système de DOS en vérifiant que tout va fonctionner.
Ici, on a une machine avec Linux, le disque que l'on va utiliser s'appelle /dev/sdc et l'image de disquette dos622_1.img.
qemu-system-i386 -m 8 -fda dos622_1.img -hda /dev/sdc -boot a

En quittant le programme d'installation (F3), FDISK permet de créer une partition (attention à la taille visible par le BIOS de 512Mio ou 2Gio, et DOS 6.2 n'allouera jamais de partitions de plus de 2Gio).
Après un redémarrage pour prendre en compte la nouvelle table de partitions (il faudra encore quitter l'installation de DOS), on peut formater la partiton avec FORMAT C: /S et redémarrer en enlevant la disquette, pour tester.
Normalement on doit obtenir quelques lignes avec un prompt :
Starting MS-DOS...

C:\>_

C'est que tout s'est bien passé et qu'on peut copier le contenu des disquettes dans un dossier du disque.

DOS
On peut remettre le disque dans le PC. Si tout ce passe bien, le pc renvoie le prompt de DOS, que l'on peut continuer à installer :
cd \DOSS
setup.exe
(les fichiers ont été installés dans le dossier C:\DOSS\)
MS-DOS 6.2 installation

Il ne reste plus qu'à se laisser guider et choisir les programmes de dos à installer.
On peut aussi installer des programmes supplémentaires (disquette 4) :
cd \DOSS
setup.bat


Quelques programmes sont spécifiques à ce PC et sont fournis par Compaq, mais seuls les SP1728 et SP1743 sont vraiment utiles.

Windows 3.1
cd \WIN31
setup.exe

L'installation est vraiment simple et rapide.
windows 3.1 installed

Windows 95
Windows 95 peut fonctionner avec 4 ou 8Mio de RAM, mais il est beaucoup plus lent que Windows 3.1, du coup je n'ai pas tenté son installation, même s'il avait été installé sur le disque dur d'origine. Windows 98 fonctionne assez mal avec moins de 32Mio de RAM, et refuse de s'installer avec moins de 16Mio.

SoftPaq
Ce sont des programmes fournis par Compaq, habituellement installés d'origine et fournis sur des disquettes :
  • SP1454 : Installe une partition d'hibernation et de recovery, mais risque de casser la table de partition...
  • SP1728 : Driver video pour DOS
  • SP1743 : Programmes de base pour Compaq Contura
  • SP2054 : Configuration du BIOS et diagnostic depuis DOS
Comme les disquettes ont disparu de la circulation, je les ai extraits.

icon Tags de l'article : , , ,

Billets de train

icon 03/01/2016 - No comments

En voyant une vidéo parlant des données présentes sur les divers codes barres des billets d'avion, j'ai regardé si les billets de train que j'avais en stock étaient pareil.
https://www.youtube.com/watch?v=jM4_iz3RqE8

J'ai trouvé 4 types de billets avec des codes-barres différents :
  • Billets cartonnés (code PDF417)
  • E-Billets TGV et IC (code PDF417 ou Aztec)
  • E-Billets TER (code Aztec)
  • Billets "Online-Ticket" ÖBB imprimés (code Aztec)

Billet cartonné
"Billet classique", commandé puis retiré a un guichet/borne, avec un code PDF-417 imprimé à gauche
Billet SNCF cartonné

A|B|CC|DDDDDD|EEEEEEEEE|F|G|HH|0000000000|II|JJ|KK|L|MMM|NNN|OOO|
PPPPP|QQQQQ|RRRRRR|SSSS|TTT|UUU|VVV|W|XXXX|YY|
     |     |      |    |   |   |   |*|    |  |

  • A : Type de format ('e' dans ce cas)
  • B : Code d'imprimante (E pour les guichets)
  • C : Code de ticket
  • D : Numéro de dossier
  • E : Numéro de billet
  • F : Drapeau de spécimen (0 pour un faux ou 1 pour un vrai)
  • G : Version de codage du billet (1)
  • H : Ticket A de B
  • 0 : Réservé (10 caractères)
  • I : Type de carte de réduction (deux espaces en cas d'absence)
  • J : Nombre de voyageurs adultes
  • K : Nombre de voyageurs enfants
  • L : Dernier digit de l'année du voyage
  • M : Date d'impression (nombre de jours depuis le 01.01)
  • N : date de début de validité, idem
  • O : Date de fin de validité, idem
Premier segment :
  • P : Gare de départ (code ISO3166-1 du pays puis code de la gare sur 3 digits)
  • Q : Gare d'arrivée, idem
  • R : Numéro de train (6 digits, ou 5 digits + '\0', ou '0' + 4 digits + '\0')
  • S : Code antifraude
  • T : Date de départ du train (nombre de jours depuis le 01.01)
  • U : Numéro de voiture
  • V : Numéro de place
  • W : Classe du voyage
  • X : Code du tarif
  • Y : Conditions du services/payements

Le premier segment (de M à V) peut être complété par un second segment suivant la même syntaxe si le voyage a des correspondances. En cas d'absence, il y a 29 espaces, la classe est notée '*', puis 6 espaces.

A|B|CC|DDDDDD|EEEEEEEEE|F|G|HH|0000000000|II|JJ|KK|L|MMM|NNN|OOO|
PPPPP|QQQQQ|RRRRRR|SSSS|TTT|UUU|VVV|W|XXXX|YY|
     |     |      |    |   |   |   |*|    |  |

e|E|DV|RUZNxx|58361xxxx|1|1|11|0000000000|  |01|00|3|165|166|226|
FRLPD|CHGVA|96506 |    |166|   |   |2|LJ25|B |
     |     |      |    |   |   |   |*|    |  |

Ici on a un un billet au format "e" pour le numéro de dossier RUZNxx, un numéro de billet 58361xxxx, le billet est valide, et il n'y a qu'un seul billet pour ce voyage. Le billet n'est pas lié à une carte de réduction, il est pour un adulte sans enfant, est vallable pour l'année 2013, a été acheté le 14.06 et est vallable du 15.06 au 14.08.
Le premier segment est au départ de Lyon Part-Dieu (France, LPD) et à destination de Genève-Cornavin (Suisse, GVA), pour le train 96506, le code est illisible, le train circule le 15.06, n'a pas de place/voiture réservée, est en 2nde classe, pour un tarif "illico Jeunes -25%" et n'est pas échangeable après le départ.

Dans tous les cas, ces billets ne posent aucun risques une fois le voyage effectué dans sa totalité (par contre il est possible de l'annuler ou de le reproduire si il n'a pas encore été utilisé).


E-Billet TGV/IC
Les E-billets sont fournis par des agences de voyages (Voyages-SNCF, Capitaine-train, E-billet-SNCF) et ont un code Aztec pour ceux imprimés soi-même ou un code PDF-417 pour ceux imprimés sur les bornes de la SNCF. Dans les deux cas ils utilisent la même organisation.

A|B|CC|DDDDDD|EEEEEEEEE|F|G|HH|II/JJ/KKKK|
LLLLL|MMMMM|NNNNN|OO/PP|
QQQQQQQQQQQQQQQQQQQ|RRRRRRRRRRRRRRRRRRR|SSSSSSSSSSSSSSSSSSS|T|UU|VVVVV|
LLLLL|MMMMM|NNNNN|

  • A : type de format (i)
  • B : 1 pour un billet imprimé à une borne (code PDF-417), 0 pour un billet imprimé soi-même (code Aztec)
  • C : Confirmation Voyage (CV)
  • D : Numéro de Dossier
  • E : Numéro d'E-Billet
  • F : Drapeau de spécimen (0 pour un faux ou 1 pour un vrai)
  • G : Version du codage du billet (2)
  • H : Ticket A de B
  • II/JJ/KKKK : date de naissance du voyageur

  • L : Gare de départ (code ISO3166-1 du pays puis code de la gare sur 3 digits)
  • M : Gare d'arrivée, idem
  • N : Numéro de train
  • OO/PP : date du trajet
  • Q : Numéro de client
  • R : Nom du voyageur (suivi ou précédé par des espaces, 19 caractères max)
  • S : Prénom du voyageur (suivi ou précédé par des espaces, 19 caractères max)
  • T : Classe
  • U : inconnu, possiblement un caractère de contrôle ou un code antifraude
  • V : Code du tarif
Le 2nd segment est facultatif et remplacé par des 0 et des espaces
  • L : Gare de départ (code ISO3166-1 du pays puis code de la gare sur 3 digits)
  • M : Gare d'arrivée, idem
  • N : Numéro de train

A|B|CC|DDDDDD|EEEEEEEEE|F|G|HH|II/JJ/KKKK|
LLLLL|MMMMM|NNNNN|OO/PP|
QQQQQQQQQQQQQQQQQQQ|RRRRRRRRRRRRRRRRRRR|SSSSSSSSSSSSSSSSSSS|T|UU|VVVVV|
LLLLL|MMMMM|NNNNN|

E-Billet SNCF cartonné

i|1|CV|RIKNxx|248713xxx|1|2|11|01/01/1990|
FRCMF|FRPAZ|05706|13/05|
0029009166230xxxxxx|BOxxxxxx          |XAVIER             |2| 2|PR112|
FRPMO|FRDLY|16757|

Ici on a un billet au format "i", imprimé sur une borne SNCF, le billet est une "Confirmation de voyage" pour le dossier RIKNxx, un numéro de billet 248713xxx, le billet est valide, et il n'y a qu'un seul billet pour ce voyage.
Le billet est au départ de Chambéry-Challes-les-eaux (France, CMF), à destination de Paris-Austerlitz (France, PAZ), avec le train 05706 le 13/05.
Le numéro d'identifiant du trajet est 0029009166230xxxxxx, porté par "Xavier Bo", voyageant en seconde classe avec un tarif "preum's"
La seconde partie du trajet est au départ de Paris Montparnasse (France, PMO) vers La-Ferté-Bernard (France, DLY), avec le train 16757.

A|B|CC|DDDDDD|EEEEEEEEE|F|G|HH|II/JJ/KKKK|
LLLLL|MMMMM|NNNNN|OO/PP|
QQQQQQQQQQQQQQQQQQQ|RRRRRRRRRRRRRRRRRRR|SSSSSSSSSSSSSSSSSSS|T|UU|VVVVV|
LLLLL|MMMMM|NNNNN|

i|0|CV|QZWKx|482593xxx|1|2|11|01/01/1990|
CHGVA|FRLPD|09744|19/12|
00290290169300xxxxxx|BOxxxxxx         |XAVIER             |2|  |    0|00000

Ici on a un billet au format "i", imprimé par ses propres moyens, le billet est une "Confirmation de voyage" pour le dossier QZWKxx, un numéro de billet 482593xxx, le billet est valide, et il n'y a qu'un seul billet pour ce voyage.
Le billet est au départ de Genève-Cornavin (Suisse, GVA), à destination de Lyon Part-Dieu (France, LPD), avec le train 09744 le 19/12.
Le numéro d'identifiant du trajet est 00290290169300xxxxxx, porté par "Xavier Bo", voyageant en seconde classe. Par contre les informations de tarif sont vides et le voyage n'a pas de correspondance (champs remplis par des espaces ou des '0').

Le ticket à plutôt l'air de servir à identifier le voyageur (I, J, K, Q, R) et à porter un numéro d'identifiant (Q) qui doit pointer dans une base de donnée mieux remplie (et non-modifiable).
Certains paramètres (O, P, T, V) ont l'air d'être présent en cas de base de donnée injoignable ou pas à jour pour au moins vérifier sommairement que personne ne tente de frauder.

E-Billet TER imprimé
Le billet a été commandé sur le site web des TER-Rhône-Alpes et a un code Aztec assez grand.
Le début contient des données encodées, puis la chaîne :

T00T100xx|0000738xx|FRLYL|FRHCZ|
S|28122015|2|NG02|INTERNET BILLET IL|BOUxxxxx XAVIER |01011990|ADULTE| |01| 00|28122015|28122015|000000000000|28122015|1313|0000000200

En interprêtant sommairement, on remarque plusieurs chaînes contenant la date du 28.12.2015 (date de commande, début et fin de validité du ticket).
La chaîne 1313 correspond à l'heure de la commande du billet, "NG02" semble être un tarif, et plusieurs champs peuvent correspondre à la classe.
Les champs FRLYL et FRHCZ sont au format des autres billets et indiquent les gares de départ et d'arrivée (France, Lyon Gorge-de-Loup et Fleurieux-sur-l'Arbresle).

Par contre je n'ai qu'un seul ticket de ce type pour l'instant, je pourrais compléter quand j'aurais comparé avec des tickets du même type pour des trajets.

Internet-Ticket ÖBB imprimé
Les tickets à imprimer soi-même sont les seuls à comporter un code-barres unique. Par contre le codage a l'air similaire et compatible avec celui utilisé par la DB (Allemagne) et les SBB (Suisse).

Pour l'instant le code comporte une en-tête dépendant du type de ticket/carte, du transporteur et de la longueur du message. Le reste est encodé en binaire mais semble décodable.

Références

icon Tags de l'article : , , ,