Parsing MARC zapisa pomoću Pythona 3 u 50 linija kôda ili manje


Otprilike 14 min. za pročitati*

*Ne uključuje mazohizam potreban za čitanje ove tematike

Uvodno

Pojavom prvih računala u knjižnicama te njihovom integracijom u rad knjižnica, uključujući i izradu kataložnih zapisa, knjižnice su bile prisiljene osmisliti format bibliografskih zapisa koji će biti strojno čitljiv, ali i omogućavati međusobnu razmjenu zapisa. Sve do tada, knjižnice su isključivo koristile kataloge na listićima. Međutim, 1968. godine, Henriette Avram i njezin mali tim dovršio je MARC format koji se i danas koristi u knjižnicama diljem svijeta. Doduše, od 1968. g. do danas MARC je doživio niz promjena i podijelio se na više grana, pa tako danas u knjižnicama prevladava nekoliko izvedenica MARC formata kao što su MARC21 i UNIMARC. Ipak, struktura gotovo svih izvedenica MARC formata u pozadini je ista, a ona se bitno razlikuje od drugih formata za razmjenu podataka kao što su BibTeX ili CSV, a za koje već postoji niz čitača i parsera. Nadalje, MARC formati vrlo su egzaktni pa nije teško s njima baratati. U ovom tekstu, napravit ćemo tzv. parser, odnosno čitač MARC zapisa, pomoću programskog jezika Python 3 u 50 linija kôda ili manje. Parser će vraćati listu rječnika. Cilj je stoga doći do strukture u kojoj dio zapisa izgleda ovako:

{
'245a': 'The Stephen King companion /',
'245c': 'edited by George Beahm.',
'260a': 'Kansas City, Mo. :'
}

Tada će biti vrlo jednostavno izvući, recimo, podatak o naslovu, koristeći samo zapis['245a']. Međutim, kako bi došli do toga, potrebno je prvo proučiti kako MARC strukturira svaki zapis.

Struktura MARC zapisa

MARC se u literaturi, ali i samim katalozima, često prikazuje romantično i strukturirano. Tako se UNIMARC zapisi u hrvatskim katalozima prikazuju na sljedeći način:

200	1#	$a	UNIMARC manual
 		$e	authorities format
 		$f	[Permanent UNIMARC Committee]
205	##	$a	2nd revised and enlarged ed
210	##	$a	Muenchen
	 	$c	K. G. Saur
 		$d	2001
215	##	$a	200 str.
	 	$d	25 cm

Stvarni izgled MARC zapisa zapravo je potpuno nečitljiv, kao što se može vidjeti iz sljedećeg primjera MARC21 zapisa:

01089cam 2200301 a 4500001000800000005001700008008004100025035002100066906004500087955006200132010001700194020001500211040001300226042000900239050002000248082001400268245012800282260004500410300003300455650003900488650003600527650002700563650003700590700002600627700003300653922004200686991005900728260925420110215170927.0970930s1996 at a b 000 1 eng  9(DLC) 97201311 a7bcbccorignewd3encipf19gy-gencatlg arc09 9-30-97; lb13 02-06-98; lb10 04-10-98; lb05 05-27-98 a 97201311  a1863885927 aDLCcDLC alcac00aPZ5b.B405 199600a[Fic]22100aBeetle soup :bAustralian stories and poems for children /ccompiled by Robin Morrow ; illustrated by Stephen Michael King. aSydney ;aNew York :bScholastic,c1996. a94 p. :bcol. ill. ;c26 cm. 0aChildren's literature, Australian. 0aAustraliaxLiterary collections 1aAustralian literature. 1aAustraliaxLiterary collections.1 aMorrow, Robin,d1942-1 aKing, Stephen Michael,eill. aap;ainvoice no. AS039446 dtd 6.27.97 bc-GenCollhPZ5i.B405 1996p00053337120tCopy 1wBOOKS

Ne dajte se obeshrabriti! MARC ionako nije namijenjen čitanju od strane ljudi, ali zato će ga naš Python parser savršeno razumijeti. Ipak, kako bi znali isprogramirati parser, moramo barem znati što svaki dio MARC zapisa znači. Kako je ovdje riječ o MARC21 primjeru, dobro će nam doći službene upute o MARC21 formatu.

Oznaka zapisa

Prva 23 znaka (počevši od broja 0, ne 1) su oznaka zapisa (eng. leader), u ovom primjeru 01089cam 2200301 a 4500. Na pozicijama 0-4 (ukupno 5 znakova) označeno je kolika je dužina cijelog zapisa (uključujući i oznaku zapisa i kazalo zapisa i sam zapis), dakle zapis iz ovog primjera dug je 1089 znakova (01089cam 2200301 a 4500). Ovo je važna informacija u slučaju kada se u jednoj datoteci nalazi više MARC zapisa, često dobivenih preko Z39.50 poslužitelja knjižnica.
Još jedan važan podatak nalazi se na pozicijama 12-16, a to je dužina oznake zapisa i kazala zapisa: 01089cam 2200301 a 4500, dakle u ovom primjeru oznaka zapisa i kazalo zapisa sadrže ukupno 301 znak.
Za parsiranje samih podataka iz zapisa, te dvije informacije iz oznake zapisa bit će dovoljne jer pomoću njih znamo točno gdje počinje i završava oznaka zapisa, kazalo zapisa i sâm zapis.

Kazalo zapisa

Kazalo zapisa u ovom primjeru nalazi se na poziciji 24-301 (24 je fiksna pozicija a 301 je već spomenuta dužina oznake zapisa i kazala zapisa).

001000800000005001700008008004100025035002100066906004500087955006200132010001700194020001500211040001300226042000900239050002000248082001400268245012800282260004500410300003300455650003900488650003600527650002700563650003700590700002600627700003300653922004200686991005900728

Za razliku od ostalih dijelova zapisa, kazalo zapisa koristi isključivo brojeve. Kazalo služi kako bi stroju reklo dužinu i početnu poziciju svakog MARC polja (npr. 100, 101, 200, itd.). Svaki skup podataka o polju uvijek ima 12 znakova, od kojih su prva 3 znaka oznaka polja, 4 znaka za dužinu polja te 5 znakova za početnu poziciju polja unutar zapisa (čistog, bez oznake zapisa i kazala zapisa). Na primjer, za polje 245 (podatak o naslovu u MARC21 formatu), skup podataka o polju unutar kazala izgleda ovako:

001000800000005001700008008004100025035002100066906004500087955006200132010001700194020001500211040001300226042000900239050002000248082001400268245012800282260004500410300003300455650003900488650003600527650002700563650003700590700002600627700003300653922004200686991005900728

Dakle polje 245 (245012800282) dugo je 128 znaka (245012800282) i počinje na poziciji 282 (245012800282). Isto vrijedi za sve ostale podatke o poljima unutar kazala.

Polja i potpolja

Unutar samog zapisa, tri su jedinstvena znaka kojim se označavaju kraj polja, potpolje i kraj cijelog zapisa. Često se pogrešno misli kako se potpolje označava znakom dolara ($), ali za sva tri znaka zapravo se koriste posebna ASCII polja. Za kraj polja korisi se \x1e, za potpolje \x1f, a za kraj cijelog zapisa \x1d. U prikazu MARC zapisa u ovom primjeru navedeni znakovi uopće se ne vide iz razloga što su oni vrsta graničnika, separatora koji nemaju fizički prikaz u stvarnom tekstu. Potpolje se dakle, samo iz razloga čitljivosti, često označava kao dolar, iako to nije.

Parser

Pomoću našeg Python parsera želimo biti u mogućnosti uvoziti više datoteka sa potencijalno više MARC zapisa. Stoga funkciju za parsing MARC-a otvaramo na sljedeći način:

def parse_marc(files):
    records_to_return = []
    field_end = "\x1e"
    subfield = "\x1f"
    record_end = "\x1d"

records_to_return sadržavat će listu obrađenih zapisa u obliku rječnika. field_end, subfield i record_end su već spomenuti posebni znakovi koje MARC koristi kako bi označio kraj polja, potpolje i kraj zapisa.

Unutar parse_marc funkcije potrebna nam je još jedna sitna funkcija koja će odvajati podatke o poljima u kazalu zapisa, odnosno koja će cijelo kazalo sjeckati na dijelove od 12 znakova, a ona izgleda ovako:

    def split_to_each_field(n, value):
        lines = [value[i:i + n] for i in range(0, len(value), n)]
        return lines

Sada možemo početi s čitanjem zapisa. Krećemo s for petljom koja će otvoriti svaku datoteku koju smo proslijedili funkciji, učitati je, zatim podijeliti na zapise koristeći se posebnim znakom \x1d za kraj zapisa, već definiranim kao record_end:

    for file in files:
        with open(file, newline="", encoding="utf8", errors="replace") as marc_file:
            marc_file = marc_file.read()
            marc_records = marc_file.split(record_end)

Kako i zadnji zapis završava s oznakom za kraj zapisa, želimo izbjeći da nam zadnji objekt u listi koju dobivamo dijeljenjem datoteke na zapise bude prazan string. Stoga ćemo zadnji, prazni, zapis obrisati s jednom linijom kôda:

del marc_records[-1]

Nakon toga možemo provesti for petlju za svaki zapis:

            for one_record in marc_records:
                temp_record = {}
                base_address = int(one_record[12:17])
                full_index = one_record[24:base_address - 1]
                fields_index = split_to_each_field(12, full_index)
                record_itself = one_record[base_address:]

Na početku ove petlje deklariramo prazni rječnik, temp_record, koji će služiti kao spremnik za svaki obrađeni zapis, koji ćemo naknadno pripojiti listi records_to_return. base_address označava dužinu oznake zapisa i kazala zapisa, ali nam služi i kao lokacija početka samih polja i potpolja. Pretvaramo je u cijeli broj, int, s obzirom da će nam u takvom obliku jedino i služiti. full_index sadrži cijelo kazalo zapisa, a ovdje je bitno naglasiti kako Python pozicije počinje brojati od 1, a ne od 0, tako da sve ranije navedene pozicije moramo povećati za 1. U slučaju full_index, to znači da kazalo počinje na poziciji 24, a završava na poziciji base_address (dužina oznake zapisa i kazala zapisa), minus 1, jer kazalo završava znakom graničnika koje ne želimo uključiti u string. Kako bi dobili informacije o svakom polju, kazalo moramo podijeliti na skupove od 12 znakova, za što će nam poslužiti funkcija split_to_each_field koju smo već napravili. fields_index stoga deklariramo kao rezultat te funkcije. Varijabla record_itself, kao što joj ime i govori, sadrži sam zapis s poljima i potpoljima koja ćemo parsirati.

Kako bi došli do samih polja i potpolja, koristit ćemo se spremnim kazalima polja, po kojima ćemo provesti, očekivano, for petlju:

                for field_info in fields_index:
                    tag = field_info[:3]
                    field_length = int(field_info[3:7])
                    starting_char_pos = int(field_info[7:12])
                    field_subfields = record_itself[starting_char_pos:starting_char_pos+field_length].split(subfield)

tag, field_length i starting_char_pos sadrže podatke o oznaci polja, dužini polja i početnoj poziciji polja, spomenutim ranije. field_subfields izvlači sva potpolja u listu tako što dijeli cijelo polje pomoću posebnog znaka subfield, popularnog “dolara”.

Kako sada imamo još jednu listu, listu potpolja, potrebna nam još jedna for petlja. Ova će služiti za pripremu potpolja:

                    for fieldSubfield in field_subfields:
                        if fieldSubfield:
                            subfield_mark = fieldSubfield[0]
                            ready_subfield = fieldSubfield[1:].replace(field_end, "").replace(record_end, "")

subfield_mark sprema oznaku potpolja (a, b, i sl.), dok ready_subfield sprema čiste podatke iz potpolja (npr. naslov, autore, i sl.).

Sada postaje zabavno. Napokon imamo sve što nam treba: specifični zapis, polje, potpolje i podatke iz potpolja. Ali moramo to spremiti na smislen način u rječnik, dict, kako bi bilo upotrebljivo. To ćemo napraviti na sljedeći način:

                            if ready_subfield.strip():
                                if tag+subfield_mark in temp_record.keys():
                                    if type(temp_record[tag + subfield_mark]) is not list:
                                        temp_record[tag + subfield_mark] = [temp_record[tag+subfield_mark]]
                                        temp_record[tag+subfield_mark].append(ready_subfield)
                                    else:
                                        temp_record[tag + subfield_mark].append(ready_subfield)
                                else:
                                    temp_record[tag+subfield_mark] = ready_subfield

Prvo s if uvjetom provjeravamo sadrži li potpolje ikakve podatke. Ako sadrži, još jednim if uvjetom gledamo postoji li već navedeno potpolje u rječniku temp_record. Ukoliko postoji, što je slučaj s poljima i potpoljima koja su ponovljiva, pripajamo ga u listu tog potpolja. Ukoliko ne postoji, jednostavno ga stavljamo u rječnik.

Nakon toga, ostaje samo pripojiti rječnik zapisa temp_record listi zapisa records_to_return:

records_to_return.append(temp_record)

I vratiti listu zapisa:

return records_to_return

Cijeli kôd, ukupno 44 linija, stoga izgleda ovako:

def parse_marc(files):
    records_to_return = []
    field_end = "\x1e"
    subfield = "\x1f"
    record_end = "\x1d"

    def split_to_each_field(n, value):
        lines = [value[i:i + n] for i in range(0, len(value), n)]
        return lines

    for file in files:
        with open(file, newline="", encoding="utf8", errors="replace") as marc_file:
            marc_file = marc_file.read()
            marc_records = marc_file.split(record_end)
            del marc_records[-1]
            for one_record in marc_records:
                temp_record = {}
                base_address = int(one_record[12:17])
                full_index = one_record[24:base_address - 1]
                fields_index = split_to_each_field(12, full_index)
                record_itself = one_record[base_address:]

                for field_info in fields_index:
                    tag = field_info[:3]
                    field_length = int(field_info[3:7])
                    starting_char_pos = int(field_info[7:12])
                    field_subfields = record_itself[starting_char_pos:starting_char_pos+field_length].split(subfield)
                    for fieldSubfield in field_subfields:
                        if fieldSubfield:
                            subfield_mark = fieldSubfield[0]
                            ready_subfield = fieldSubfield[1:].replace(field_end, "").replace(record_end, "")
                            if ready_subfield.strip():
                                if tag+subfield_mark in temp_record.keys():
                                    if type(temp_record[tag + subfield_mark]) is not list:
                                        temp_record[tag + subfield_mark] = [temp_record[tag+subfield_mark]]
                                        temp_record[tag+subfield_mark].append(ready_subfield)
                                    else:
                                        temp_record[tag + subfield_mark].append(ready_subfield)
                                else:
                                    temp_record[tag+subfield_mark] = ready_subfield

                records_to_return.append(temp_record)

    return records_to_return

Korištenje parsera

S obzirom da parser vraća listu rječnika, vrlo je jednostavno izvlačiti podatke iz obrađenih zapisa. Kako bi uopće parsirali zapise, pozvat ćemo funkciju parse_marc unutar deklaracije varijable records. Recimo da imamo dvije datoteke, marc1.mrc i marc2.mrc koje želimo parsirati:

records = parse_marc(["file1.mrc", "¸file2.mrc"])

Zatim, primjerice, ukoliko želimo iz svakog zapisa ispisati podatak o naslovu:

for record in records:
    print(record["245a"])

Za svaki slučaj, ukoliko želimo izbjeći pogreške (u slučaju da nema podatka o naslovu), možemo provjeriti sadrži li uopće rječnik ključ 245a:

for record in records:
    if "245a" in record.keys():
        print(record["245a"])

Zaključno

Iako se može činiti kako je MARC iznimno kompliciran, za strojeve je on i dalje lako čitljiv format, što je vidljivo iz našeg parsera koji čita više MARC zapisa koristeći samo 44 linije kôda. Cijeli kôd je svakome dostupan na GitHub-u.