Her måtte Bash og Grep give op: Vi skriver et værktøj til at udtrække data fra aktindsigt

Illustration: Bigstock/REDPIXEL.PL
Kommandolinje-værktøjer kan bruges til meget. Men hvis man ikke kan finde det rigtige til opgaven - hvorfor så ikke bare skrive sit eget?

Nu skal det jo ikke handle om COVID-19, det hele. Sådan tænker jeg i det mindste.

En dag fik min kollega en ordentlig bunke aktindsigt fra Datatilsynet, i forbindelse med Aula-lækket, som Version2 afslørede for nylig.

Læs også: Sagerne vælter ind: Kommunerne har anmeldt 136 sikkerhedsbrud i Aula siden nytår

Der var tale om de blanketter, der skal udfyldes, når en virksomhed eller myndighed ved, at der har fundet et databrud sted, og skal anmelde dette til Datatilsynet.

Dokumenterne er som regel PDF’er i et maskinlæsbart format, hvor teksten kan udtrækkes i en eller anden form.

Masser af tekst

Der er tale om 168 dokumenter. Det vil tage en hulens lang tid at copy-paste de relevante informationer til en form, vi kan bruge til noget. Og det er næppe heller sundt for håndleddet. PDF'er er skidt for os, der skal hive tekst ud.

I første omgang vil vi gerne have en oversigt over

  • det enkelte dokuments filnavn,
  • myndigheden, der har indsendt aktindsigten,
  • dato og tidspunkt,
  • og så vil vi gerne have det, der står i punktet ‘Beskriv hændelsen’.

Og gerne det hele på en nem og overskuelig facon, såsom en kommasepareret fil, som man kan åbne i et regnearksprogram.

Problemet er i og for sig ikke så svært. Alle blanketterne er ens, og de felter, vi vil udtrække, er omkranset af samme tekst. Altså lige et job for go’e gamle grep.

Læs også: Sådan finder vi forsker-navne med Linux-kommandoer på Windows

Første skridt på dagsordenen er at udtrække teksten fra PDF-filerne. Jeg bruger Bash-shell’en i Ubuntu, under Windows Subsystem for Linux (WSL), som jeg plejer.

Her kan jeg udtrække teksten med Pdftotext-programmet, ved at navigere hen til mappen med dokumenterne og bruge one-liner’en:

for file in *.pdf; do pdftotext "$file" "$file.txt"; done

Nu er der en tekst-udgave af hver af PDF-dokumenterne, med ‘.txt’ i enden af filnavnet, i mappen.

Tekstmønstre trækker tekst ud

Det går jo strygende. Nu skal vi kigge nærmere på tekstfilerne. Vi vil som sagt gerne opsnappe dato og tid. Vi finder tidspunktet i tekstklumpen:

Skema til indberetning af sikkerhedshændelser
 
Identifikation
Tidspunkt for indberetningen
onsdag maj 15, 2019 13:20:03
 
Referencenummer
a93068d77aca5b0ae2991b8af07021ed6f93da6e
 
Anmelderinformation

Vores tekstmønster i regular expressions - programmeringsverdens universelle sprog til mønstergenkendelse i tekst - skal altså være noget i stil med

Tidspunkt for indberetningen(.*)Referencenummer

Punktum betyder ‘alle tegn’, stjerne betyder ‘nul eller flere gange’, og parentesen angiver en ‘capture group’, som er den tekst, vi vil indfange.

Grep kan ikke finde ud af kun at returnere capture group’en, så jeg bruger alternativet Pcregrep - ‘a grep with Perl-compatible regular expressions,’ som der står på manual-siden.

Det betyder, at jeg kan bruge den helt fantastiske og gratis webapp Regex101.com til på interaktiv vis at forfine mit søgemønster, så den gør det, jeg vil have. Efter lidt nørkleri når jeg frem til dette:

pcregrep -M -o1 "(?s)Tidspunkt for indberetningen(.*?)Referencenummer" 2019-442-4960.pdf.txt

Flaget M står for ‘søg i hele teksten og ikke blot enkelte linjer’, o1 betyder ‘udskriv første capture group’. Det mystiske ‘?s’ i starten af selve søgestregen siger, at punktum-symbolet, som altså betyder 'alle tegn', også matcher linjeskift. Vores match kan altså løbe over linjeskift.

Vores capture group ser nu sådan ud: (.*?) og her betyder spørgsmålstegnet, at gruppen ikke skal være ‘grådig.’

Det kræver en lille forklaring. I nogle af PDF’erne er der mere end én blanket, fordi myndighederne har indsendt flere anmeldelser til Datatilsynet og samlet dem i ét PDF-dokument.

Hvis spørgsmålstegnet udelades, finder mønsteret den størst mulige streng, og det er så den, der går fra starten af mønsteret i første dokument og frem til slutningen af mønsteret i det andet dokument. Med spørgsmålstegnet finder vi kun lige det, vi vil have - nemlig dato og tid.

Øv, mit dumme program

Ak! Det er ikke lige så nemt. Jeg bruger en tåbelig mængde tid på at forfine min kommando, som nu er gået hen og blevet til et helt lille script i Bash:

echo "" > ../ud2.txt
for file in *; do
  echo $file >> ../ud2.txt
  pcregrep -M -o1 -e "(?s)Tidspunkt for indberetningen(.*?)Referencenummer" "$file" | sed -e 's/^[[:space:]]*//' >> ../ud2.txt
  echo -e "," >> ../ud2.csv
  pcregrep -M -o1 -e "(?s)virksomhedens navn(.*?)Afdeling" "$file" | sed -e 's/^[[:space:]]*//' >> ../ud2.txt
done

(For enkelthedens skyld er det her kun de to første felter i filerne, som vi udtrækker.)

Men resultatet er slet ikke så raffineret som det, jeg gerne vil have. Jeg kan ikke se, hvordan jeg kommer fra et tekstfil, der har felterne med, og over i et kommasepareret format.

Det kan dog sagtens lade sig gøre, for bash-scripting og sed er regulære programmeringssprog. Men det bliver næppe enkelt eller elegant.

Jeg må smide håndklædet i ringen og se i øjnene, at det for mig er hurtigere at skrive et program i et sprog, jeg kender.

Men hvorfor ikke gøre en dyd af nødvendigheden og skrive et værktøj, der på simpel vis kan udtrække tekst og pløje det lige ind i et csv-dokument? Det er der nok andre, der kunne få brug for, såsom journalister og andet godtfolk.

Ligesom i eventyret om konen med æggene svimler jeg hen, og forestiller mig en dag, hvor man blot skal skrive

extract2csv

og så

Command 'extract2csv' not found, but can be installed with:
 
sudo apt install extract2csv

Derefter følger berømmelsen og pengene, naturligvis. I hvert fald i fantasien.

Nemme tekstmønstre

Men fantasi eller ej, programmet skal skrives først. Som udgangspunkt forestiller jeg mig, at teksten er udtrukket fra kildedokumenterne med pdftotext, eller docx2txt eller andre programmer der kan udtrække tekst fra forskellige slags formater.

Jeg vender tilbage til webappen Regex101.com, som jeg altså bruger til at udvikle mit søgemønster. Webappen har en rigtig smart funktion, som skriver søgemønsteret om til kode i Perl, Javascript og mere til, som man lige kan kopiere ind i sit program. Jeg bruger Java, og kan godt huske, at jeg syntes at api’et til regular expressions kan være lidt kringlet. Her klikker jeg bare på en knap, og så er mit program halvt færdigt.

Det næste punkt går ud på at skrive den fundne tekst i et kommasepareret dokument. Det gjorde vi i en Version2-artikel for et par år siden, og det var ganske nemt.

Så det springer vi over her.

Jeg tænker mig at den nemmeste syntaks for mønstre er noget i stil med:

extract2csv "Tidspunkt for indberetningen*Referencenummer" "virksomhedens navn*Afdeling" "Beskriv hændelsen*Hvor fandt hændelsen fysisk sted"  

Bum - så er der en kommasepareret fil med de rigtige felter, med navnet out.csv, i mappen.

Illustration: Version2

Sådan en simpel kommando kan journalister vel godt finde ud af - i hvert fald sådan nogen af slagsen, vi har her på Version2 og Ingeniøren.

Inde i mit lille program udskifter jeg blot stjernen i det forsimplede søgemønster med regexp-udtrykket (.*?) som vi så tidligere.

Koden er lagt på Github, og der er plads til forbedringer. Jeg har ikke nået at skrive en eneste unit-test, og jeg kan allerede få øje på en kæmpe fejl.

Det ville også være smart med lidt flere muligheder, så som at brugeren selv kan vælge ud-filen, med mere. Og det ville være godt at producere noget nemmere eksekverbart, så som en jar-fil, eller måske compile til maskinkode?

Og hvad med Githubs indbyggede continuous integration, det kan vel også noget? Vi kigger nærmer på mulighederne i næste artikel, så bliv hængende på kanalen.

Tips og korrekturforslag til denne historie sendes til tip@version2.dk
Følg forløbet
Kommentarer (15)
sortSortér kommentarer
  • Ældste først
  • Nyeste først
  • Bedste først
Tom Paamand

Jeg har selv tusinder af PDF-filer, der er elendigt navngivet. Dem graver jeg på samme måde ned i, og udtrækker relevante stumper. Da de modsat jeres opgave er meget forskellige, skraber jeg et antal muligheder fri, og tester dem så mekanisk for brugbarhed.

Herefter sidder jeg med en kommasepareret fil som i jeres eksempel, hvor gode navne på måske 80 procent af PDF-filerne relativt nem kan klippes fri. Herfra navngives dokumenterne, og Document Properties udfyldes. Jeg kalder diverse hjælpeprogrammer via PHP, som jeg bruger til at afvikle det hele med.

Samme mekanik har jeg brugt til at rense og ensrette gamle HTML-sider med, som var skrevet i et utal af editorer. En del af dem er så puttet pænt på plads i CMS-kasser med CSV hvor det var muligt, og ellers med en trænet auto-mus. Dejligt systematisk arbejde :).

  • 2
  • 0
Simon Lodal
#!/usr/bin/perl  
use strict;  
use warnings;  
   
print("filename;time;refnum\n");  
while (<*.pdf.txt>) {  
    my ($filename, $filehdl, $text);  
    $filename = $_;  
    open($filehdl, $filename) or die;  
    $text = join('', <$filehdl>);  
    print($filename);  
    print(($text =~ /Tidspunkt for indberetningen[\r\n]+(^.+$)/gm) ? ";$1" : ";ERROR");  
    print(($text =~ /^Referencenummer[\r\n]+(^[^ ]+$)/gm) ? ";$1" : ";ERROR");  
    print("\n");  
}
  • 4
  • 0
Baldur Norddahl

Traditionelt bruger mange Perl eller Python når Bash ikke er nok. Jeg har fundet at Ammonite er et stærkt alternativ hvis man har baggrund i Java. Det er godt nok Scala men man har automatisk adgang til alle Scala og Java biblioteker. Det er bare en script fil ligesom bash og det skal ikke oversættes/compiles før det køres.

Den første import henter automatisk det nødvendige CSV bibliotek første gang scriptet køres.

Lidt længere end Perl programmet men Perl programmet gør hellere ikke præcis det samme som Java programmet.

Ammonite: https://ammonite.io/

#!/usr/bin/env amm  
   
import $ivy.`com.github.tototoshi::scala-csv:1.3.6`  
import com.github.tototoshi.csv.CSVWriter  
import java.nio.file.Files  
import java.nio.file.Paths  
   
val defaultFilename = "out.csv"  
   
@main  
def text2csv(patternList: String*) = {  
  val csv = CSVWriter.open(defaultFilename)  
   
  val filesInDir = Paths.get("").toAbsolutePath.toFile.listFiles.toList.map(_.toPath).filter { file =>  
    Option(Files.probeContentType(file)).map(_.contains("text/plain")).getOrElse(false)  
  }  
   
  val regexList = patternList.map(_.replace("*", "(.*?)")).map( p => s"(?s).*?$p.*".r ).toList  
   
  for ( file <- filesInDir ) {  
    val text = scala.io.Source.fromFile(file.toString).mkString  
    val row = for (regex <- regexList) yield text match {  
      case regex(str) => str  
      case _ => ""  
    }  
    csv.writeRow(file.toString :: row)  
  }  
   
  csv.close  
}
  • 3
  • 0
Ulf Herold-Jensen

Hvorfor ikke bare eksportere en txt fra pdf'en og behandle den som man har behov for?
Acrobat kan det meste selv. Ja, der er 168 dokumenter, men Acrobat kan behandle "multiple documents" uden bøvl.
Som skrevet: Eli5, jeg forstår ikke behovet for at fedte med kode for at udtrække den slags fra en pdf?

  • 1
  • 0
Jens Frederik Dalsgaard Nielsen

meget flot og god kode folk skriver - jeg mener det :-).
Men tror måske at newcommers til scripting verden står af-

så tilladt mig et enkelt simpelt bondescript :-)

Problemet er tilfældige linineskift så vidt jeg kan gennemskue. TIl gengæld er der kendte faste ord som "delimiters".
Så jeg snupper lige de første 1000 linier i filen , fjerner linie skift og indsætter så linieskift før efter de kendte ord. Klipper filen(linier) fra starten af og hver gang der er en interesant linie klipper jeg den ud gemmer den. bash, head, tail, sed, cat og tr er vist det hele.

Det kunne være folk der ikke er experter kan stave sig igennem det. Helt efter KISS :-)

Det dumpes filer hele vejen så uden alt det echo ca 40 linier der sagtens kan komprimeres
ineffektiv kode, men på en anden side

1000 filer 15 sekunder(uden udskrift) - en hel dagsrejse :-) på min gamle latitude e7440

men så kan jeg også forstå mit lille micro script om 3 år ;-)

Så ej til produktion men til diy hjemme :-) og måske til at komme igang med gamle linux/unix progs :-)

mvh JEns

#!/bin/bash  
   
if [ "$#" -ne 1 ]; then  
    echo "Illegal number of parameters"  
    exit  
fi  
   
rm -f tmp*  
   
head -n 1000 $1 > tmp.1000  
   
   
echo "remove line shift"  
tr '\n\r' '  ' < tmp.1000 > tmp.step1  
cat tmp.step1  
echo ""  
   
echo "rm all uptil beretningen"  
sed 's/.*beretningen//' < tmp.step1 > tmp.step2  
cat tmp.step2  
echo ""  
   
echo "insert line shift before Ref(erencenummer)"  
sed 's/Ref/\nRef/' < tmp.step2 > tmp.step3  
cat tmp.step3  
echo ""  
   
   
echo "first line is  date - save it"  
head -1 tmp.step3 | sed 's/ *//' > tmp.date  
cat tmp.date  
   
echo "remove first line (date line)"  
tail -n +2 tmp.step3 > tmp.step4  
   
echo ""  
   
echo "insert lineshifte after ...nummer"  
sed 's/nummer/nummer\n/' < tmp.step4 > tmp.step5  
cat tmp.step5  
echo ""  
   
echo "insert lineshifte bef Anmelderr"  
sed 's/Anmelder/\nAnmelder/' < tmp.step5 > tmp.step6  
cat tmp.step6  
echo ""  
   
echo  "take line no 2 = ref nummer"  
tail -n +2 tmp.step6 | head -1 | sed 's/ *//' > tmp.refnummer  
echo "result"  
cat tmp.refnummer  
cat tmp.date  
   
echo "make oneliner with ext cvs ins linefeed at end"  
cat tmp.date tmp.refnummer | tr '\n' ';' | sed 's/;$/\n/' > $1.cvs  
   
rm tmp.*  
 
  • 1
  • 0
Jens Frederik Dalsgaard Nielsen

mere hug :-)

1000 filer 4 sekunder :-)

output i fil

det er en linie :-)  
onsdag maj 15, 2019 13:20:03    ; a93068d77aca5b0ae2991b8af07021ed6f93da6e 

kode

#!/bin/bash  
   
if [ "$#" -ne 1 ]; then  
    echo "Illegal number of parameters"  
    exit  
fi  
   
head -n 1000 $1 | tr '\n\r' ' ' | sed 's/.*beretningen//' | sed 's/Referencenummer/ ; /' | sed 's/Anmelder.*//' | sed 's/$/\n/' > $1.csv  
 
  • 1
  • 0
Baldur Norddahl

Gentagne kald til sed vil ikke virke hvis du skal finde tre felter (og det var opgaven), men hvis vi skal lave det hack (og det er et hack) så kan det gøres i et huk, ligesom det her:

baldur@DESKTOP-5F7IMRQ:~$ amm  
Loading...  
Welcome to the Ammonite Repl 2.0.4 (Scala 2.13.1 Java 11.0.6)  
   
@ scala.io.Source.fromFile("test.txt").getLines.mkString(" ").replaceFirst(".*indberetningen (.*?) +Referencenummer +(.*?) +Anmelderinformation.*","test.txt;$1;$2")  
   
res0: String = "test.txt;onsdag maj 15, 2019 13:20:03;a93068d77aca5b0ae2991b8af07021ed6f93da6e"
  • 0
  • 0
Ulf Herold-Jensen

@Jens,
For dokumentets/pdf'ens indhold er det jo nok at klikke >File > Export to > og så vælge sit format; Rich text eller andet. Det giver en glimrende tekst-fil som kan manipuleres efter forgodtbefindende.
Efter at have nærlæst, så ser det ud til at vi i første omgang skal bruge meta-data. Det kan vi ikke umiddelbart trække ud uden at "programmere". Det kan gøres med vb-scripting (det har jeg selv tidlgiere benyttet) eller det kan også gøres med java-scripting inde fra Acrobat.

  • 0
  • 1
Frithiof Andreas Jensen

Hvor er det trist at se, at Datatilsynet ...


Det ved jeg ikke helt. Måske er der fornuftigt nok.

Nu ved jeg ikke hvad format de aktuelle PDF-filer er, men en delmängde af PDF formaterne (PDF/A ...) er en ISO-standard, ISO 19005-1:2005, som er udviklet til 'long term archiving' og formatet bruges en hel del rundt omkring, isär i USA.

"Det Offentlige" burde derfor også bruge PDF/A. Jeg mener at man bare må leve med at lobbyisterne også vandt denne runde. PDF er en standard i dag, så man kan lige så godt få det bedste ud af situationen som den nu er.

Standardiseringen betyder at man kan anvende färdigt arkiv-software i stedet (måske udvikle noget Open Source API til offentlige dokumenter eftersom skatteyderne betaler, men det sker jo nok ikke)!


PS:

Man kan (det meste af tiden) konvertere de her stupide PDF-er som kun består af en TIFF-blok indpakket i PDF (som min billige scanner laver) til PDF/A format med noget software der hedder OCRmyPDF.

Det er også meget nyttigt hvis man lige har väret til en konference og har fået et par tusind "aller-laveste-fällesnävner digitaliserede" papers som man da også gerne ville lade sin MacBook söge i bagefter.

  • 0
  • 0
Log ind eller Opret konto for at kommentere