Današnje predavanje bo imelo dva do tri smotre.

Markdown

Markdown je preprost jezik, namenjen osnovnemu oblikovanju besedil: omogoča naštevanje, krepak in poševen tisk, citiranje, vstavljanje naslovov programske kode in slik ter spletne povezave. Uporabljamo ga povsod na spletu (in še kje drugod), kjer bi želeli nekoliko oblikovano besedilo. V markdownu so napisani tile zapiski, vse, kar objavljam na Učilnici, z njim lahko pišete komentarje na različne spletne strani.

Jezika se je trivialno naučiti: kar zapremo med zvezdice, je izpisano poševno (ker sem napisal *poševno*), če sta zvezdici dve, pa krepko (ker sem napisal **krepko**). Alineje zapišemo z minusi in se izpišejo

če jih želimo oštevilčiti,

  1. pa
  2. jih
  3. oštevilčimo.

Pred naslove damo en, dva, tri, štiri ali kolikor je treba znakov # (več ko jih je, manj pomemben je naslov).

Povezave zapišemo [tako, da v oglate oklepaje damo besedilo](https://ucilnica.fri.uni-lj.si), v oklepajih pa povemo, kam povezava vodi.

Vse, kar damo v vzvratne narekovaje (backtick), se izpiše kot "koda"; zgoraj sem to uporabljal, da se je besedilo med zvezdicami ali v povezavi izpisalo z zvezdicami in oglatimi oklepaji, ne pa spremenilo v poševen in krepak tisk ali v povezavo.

Kdor hoče izvedeti več bo pogledal na plonkec, vendar tudi tam ni kaj več, saj dosti več ne obstaja.

Jupyter

Besedilo znotraj teh zapiskov je napisano v markdownu - znotraj Jupytrovega notebooka. Uporabljamo ga že vse leto, z neveliko uvoda na začetku. Po eni strani ga ni težko začeti uporabljati, po drugi pa zna biti frustrirajoč, kadar želiš kaj narediti drugače, kot hoče.

Za tako pomemben in vseprisoten projekt ima fascinantno zanič dokumentacijo za nekoga, ki ni ravno računalnikar. Na uradnih straneh je še najbolj uporabno poglavje Network basics. Vse ostalo, kar sem našel, so bile strani na raznih "content farmih" in spletnih mestih, ki niso dovolj odprta, da bi nanje pošiljal študente.

Toliko o tem v zapiskih. Kdor hoče vedeti več, naj vpraša na predavanjih.

HTML

Spletne strani so zapisane v jeziku HTML. Ta je podoben XML, ki smo ga spoznali prejšnjič.

XML je splošnejši. XML služi zapisu in prenašanju poljubnih podatkov - od zapisov pretečenih ali prekolesarjenih poti v Stravi s standardiziranim formatom GPX do vektorskih slik v formatu SVG. Vsak od njih uporablja svoj specifičen nabor značk, ki jih zapisujemo v <...>. Vsak element je potrebno začeti in zaključiti (na primer <track> za začetek opisa poti in </track> za konec) in biti morajo pravilno gnezdeni. Če je znotraj elementa <track> element <trkpoint>, je potrebno slednjega zapreti pred prvim.

Tako kot sta GPX in SVG konkretni obliki XMLja, namenjeni zapisovanju poti in vektorskih slik, je tudi HTML oblika XMLja, namenjena opisovanju spletnih strani. Bistvena - in za na žal pomembna - razlika je v tem, da so bralniki za HTML bolj tolerantni glede zapiranja elementov. Če, na primer, nek element zapremo prezgodaj, preden smo zaprli notranje, bo bralnik kar sam od sebe zaprl tudi notranje. Če zapremo element, ki ga sploh nismo odprli, bo bralnik to kar ignoriral. XML mora imeti en sam korenski element, ki vsebuje vse ostale. HTML ga ima samo, če je lepo vzgojen. Če ni, bralnik pač skomigne z rameni in dokument vseeno normalno prebere. Tako so naredili zato, ker so bile v davnini spleta spletne strani pogosto natipkane ročno, kot HTML in tipkali so jih tudi ljudje, ki niti niso računalnikarji, zato smo se jih (računalnikarji) usmilili. Spletno stran je bilo vseeno možno pokazati in če je bila zaradi napak v uporabi značk videti čudno, jo je avtor pač popravil.

Posledično pa HTMLja ni tako preprosto brati s programom. Pravzaprav sta razloga dva.

Osnove HTML

Da bomo lahko iz HTML karkoli prebrati, moramo vsaj približno razumeti njegovo strukturo.

Značke

Tule so osnovne značke (osnovne v smislu, da jih bomo potrebovali pri razbiranju podatkov, ne sestavljanju strani).

Atributi

Elementi imajo atribute, ti bodo pri iskanju podatkov pomembni, ker ima stran lahko ogromno značk DIV ali A, nas pa zanimajo tiste, ki so označene z določenim atributom. Tule so najpomembnejši

Tako atributov kot značk je še na stotine in stotine. Tule smo jih spoznali toliko, da bo dovolj za nekaj osnovnih primerov in vaj.

URLji

Povedati moramo še nekaj o URLjih. Ti so, podobno kot poti do datotek, lahko absolutni ali relativni in še hujše.

Kot za vse v teh zapiskih, velja tudi za URL: spletni razvijalci niso brez razloga med najboljše plačanimi programerji. Tule zgolj približno (in mestoma narobe) praskamo po površju nečesa, kar zahteva leta treninga.

Branje HTML

Za branje HTML bomo uporabili knjižnico Beautiful Soup. Te ne dobimo s Pythonom, temveč si jo moramo namestiti dodatno.

V Jupytru to najlažje storimo tako, da vtipkamo

%pip install bs4

in po potrebi ponovno poženemo Python (Kernel / Restart).

Zdaj bomo s strani projekta Gutenberg prebrali imena vseh avtorjev, katerih priimek se začne s črko B. Najdemo jih na strani https://www.gutenberg.org/browse/authors/b.

from urllib.request import urlopen

html = urlopen("https://www.gutenberg.org/browse/authors/b").read()

Kot se, upam, spomnimo, smo dobili bajte - poglejmo začetek.

html[:100]
b'<!DOCTYPE html>\n<html class="client-nojs" lang="en" dir="ltr">\n<head>\n <meta charset="UTF-8"/>\n\n<tit'

Videti je kar normalno, kot ASCII, vendar kasneje vsebuje vse mogoče, od portugalski obvijuganih a-jev, pa do kitajščine, zato ga moramo odkodirati kot UTF. (Mimogrede, tudi sam, že v zgoraj napisanem, pove, da je njegov "charset" enak "UTF-8".

html = html.decode("utf-8")
html[:100]
'<!DOCTYPE html>\n<html class="client-nojs" lang="en" dir="ltr">\n<head>\n <meta charset="UTF-8"/>\n\n<tit'

In zdaj preberemo HTML v drevo značk, hvala Beautiful Soup.

from bs4 import BeautifulSoup

soup = BeautifulSoup(html)

Pa zdaj? Zdaj je potrebno zares pogledati spletno stran, njeno kodo in ugotoviti, v kakšnih značkah so zapisana imena avtorjev.

Gremo na stran in pritisnemo Ctrl-Alt-I na Windows oz. Cmd-Alt-I na macOS; to deluje v Chromu, Firefoxu in Safariju, kdor uporablja kaj, česar nisem poskusil, pa naj poskuša sam. V spodnjem (ali desnem, odvisno od nastavitev) delu okna se pojavi drevo s strukturo HTML, ki ga gledamo v gornjem (ali levem) delu.

Če se zdaj z miško vozimo po elementih drevesa v spodnjem delu, nam osvetljuje pripadajoče elemente v gornjem. In obratno: če izberemo tisti kvadratek s puščico, ki je v vrstici med zgornjim in spodnjim delom okna, lahko klikamo na elemente v gornjem delu in v spodnjem se pokaže ustrezni element drevesa. Če, recimo, kliknem Charlesa Babbagea, se zgodi tole.

Očitno so imena avtorjev znotraj H2, torej kot podnaslovi. Vsebujejo dve znački a. Prva nastavi povezavo znotraj strani, name, na neko kodo - za Babbagea je to a556. To pomeni, da nas povezava na stran https://www.gutenberg.org/browse/authors/b#a556 (URL te strani z dodatnim #556) namesto na začetek strani postavi na Babbagea. Znotraj te povezave je zapisano njegovo ime in letnici rojstva in smrti. Sledi še en element a, ki vsebuje besedilo "¶". povezava pa kaže na https://www.gutenberg.org/browse/authors/b#a556. Če kliknemo ta ¶, se stran ne spremeni, spremeni pa se naslov v brskalniku, tako da ga lahko skopiramo ali shranimo. (Zaradi prvega a je tudi ime videti kot povezava, vendar ne naredi ničesar. Take imam res rad.)

Zdaj najbrž vemo, kje poiskati imena avtorjev: poskusili bomo kar vse elemente a, ki vsebujejo name. Zgodi se lahko troje

Seveda je v igri tudi varianta 4, po kateri sicer ne dobimo vseh imen avtorjev, pač pa dobimo dodatno šaro.

Če se bo zgodilo prvo, bomo razmislili, kako bi bili bolj specifični. Morda bomo rekli, da mora biti ta a znotraj h2. Če se bo zgodilo drugo, bomo morali pregledati manjkajoče avtorje in razmisliti, kaj je specifičnega zanje, kako jih najti in ali bi lahko na enak način našli tudi druge.

Poslušajmo latince, ki so vedeli povedati, da audentes fortuna iuvat. soup ima metodo find_all, ki kot argument prejme ime elementa in vrne vse elemente s tem imenom.

soup.find_all("a")[:10]
[<a class="no-hover" href="/" id="main_logo">
 <img alt="Project Gutenberg" draggable="false" src="/gutenberg/pg-logo-129x80.png"/>
 </a>,
 <a href="/about/">About
           <span class="drop-icon">▾</span>
 </a>,
 <a href="/about/">About Project Gutenberg</a>,
 <a href="/policy/collection_development.html">Collection Development</a>,
 <a href="/about/contact_information.html">Contact Us</a>,
 <a href="/about/background/">History &amp; Philosophy</a>,
 <a href="/policy/permission.html">Permissions &amp; License</a>,
 <a href="/policy/privacy_policy.html">Privacy Policy</a>,
 <a href="/policy/terms_of_use.html">Terms of Use</a>,
 <a href="/ebooks/">Search and Browse
       	  <span class="drop-icon">▾</span>
 </a>]

Kar takoj posvarimo: župa se obnaša nenavadno. Če se zatipkamo pri imenu metode, vrne None namesto AttributeError.

print(soup.tralala)
None

To počne zato, ker nam soup.tralala ali soup.kajdrugega vrne (prvo) značko tralala oziroma kajdrugega v dokumentu. Če ne obstaja (točneje: ker ne obstaja), pač vrne None namesto napake. To nas tepe, če, recimo, pozabimo _ v find_all.

soup.findall("a")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 soup.findall("a")

TypeError: 'NoneType' object is not callable

soup.findall je None, zato ga ni mogoče poklicati niti z "a" niti s kakimi drugimi argumenti. Oziroma sploh.

No, če smo se že naučili, kako poceni priti do elementa a, pograbimo kar prvega, ki pride pod roko.

link = soup.a

Kako izvemo, da se v njem ne skriva ime avtorja? Kot smo rekli: elementi z imeni avtorjev. Vse elementi župe imajo atribute attrs, ki vsebuje vse atribute. Del HTMLja, ki vsebuje naš link, je

link
<a class="no-hover" href="/" id="main_logo">
<img alt="Project Gutenberg" draggable="false" src="/gutenberg/pg-logo-129x80.png"/>
</a>

Atributi so class, href in id. Res je:

link.attrs
{'id': 'main_logo', 'href': '/', 'class': ['no-hover']}

(Atribut class je seznam, ker lahko element pripada več razredom. Mimogrede tu vidimo še en primer uporabe id-ja: označuje main_logo. Najbrž zgolj zaradi oblikovanja: v pravilih za oblikovanje strani so določili, kako naj bo oblikovan element z id-jem main_logo, namesto elementi v razredu main_logo; tako so storili zaradi higiene, saj stvari, ki so edinstvene, niso "razred zase".)

Zdaj znamo poiskati elemente z avtorji: v slovarju attrs morajo imeti name. Poiščimo prvega, da se bomo lahko igrali z njim in ugotovili, kako izvleči ime avtorja.

for link in soup.find_all("a"):
    if "name" in link.attrs:
        break
link
<a name="a32541">Baader, Bernhard, 1790-1859</a>

Da, vidite je, da smo ujeli človeka. Najbrž res prvega po abecedi med vsemi na B. :)

Elementi imajo atributa string in strings.

link.string
'Baader, Bernhard, 1790-1859'
link.strings
<generator object Tag._all_strings at 0x10d7048b0>

Drugi je videti manj privlačno. :)

Pripravimo slovar, katerega ključi bodo avtorji, vrednosti njihove notranje povezave na strani.

authors = {}
for link in soup.find_all("a"):
    if "name" in link.attrs:
        authors[link.string] = link.attrs["name"]
        
len(authors)
3545
list(authors)[:10]
['Baader, Bernhard, 1790-1859',
 'Baadsgaard, Anna, 1865-1954',
 'Baarslag, C.',
 'Bååth, A. U. (Albert Ulrik), 1853-1912',
 'Babbage, Charles, 1791-1871',
 'Babbitt, Ellen C.',
 'Babbitt, George Franklin, 1848-1926',
 'Babbitt, Harold E. (Harold Eaton), 1888-1970',
 'Babbitt, Irving, 1865-1933',
 'Babbitt, Katharine']

Odlično: dobili smo samo avtorje in, če na hitro pogledamo, so najbrž vsi.

Ker bi bilo s tem slovarjem zdaj lepo nekaj narediti, ga shranimo v Markdown. :) Ta bo vseboval same alineje: vsaka bo ime avtorja in bo delovala kot povezava na stran avtorjev na B, konkretno na točno tega človeka znotraj strani.

f = open("avtorji-na-b.md", "w")
url = "https://www.gutenberg.org/browse/authors/b"
for auth, aname in authors.items():
    f.write(f"- [{auth}]({url}#{aname})\n")
f.close()

Kam s to datoteko? Uporabimo jo pač kje, kjer sprejemajo Markdown. Lahko, recimo, skopiramo nekaj prvih vrstic sem, v celico notebooka.

In potem kliknemo povezavo, da vidimo, ali res deluje.

Malo preprostejše iskanje

Koda, s katero smo iskali elemente a, je bila brez potrebe prezapletena. find_all sprejema še več argumentov; med drugim lahko zahtevamo, da ima element določen atribut.

Takole dobimo vse a-je, ki imajo definiran id. Izpišimo prvih deset:

soup.find_all("a", id=True)[:10]
[<a class="no-hover" href="/" id="main_logo">
 <img alt="Project Gutenberg" draggable="false" src="/gutenberg/pg-logo-129x80.png"/>
 </a>]

Takole pa bi dobili a, katerega id je enak main_logo.

soup.find_all("a", id="main_logo")
[<a class="no-hover" href="/" id="main_logo">
 <img alt="Project Gutenberg" draggable="false" src="/gutenberg/pg-logo-129x80.png"/>
 </a>]

V resnici nas ne briga id. Najti želimo tiste, ki imajo določen name.

soup.find_all("a", name=True)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[21], line 1
----> 1 soup.find_all("a", name=True)

TypeError: Tag.find_all() got multiple values for argument 'name'

Zato sem kot primer uporabil ne prav smiselni id. Problem je v tem, da je name ravno ime prvega argumenta, tistega, ki smo mu dali vrednost a. Python se zato razjezi, da vrednosti argumenta name ne smemo nastaviti dvakrat, enkrat na "a" in potem še na True.

Podobno je s class.

soup.find_all("a", class="no-hover")
  Cell In[22], line 1
    soup.find_all("a", class="no-hover")
                       ^
SyntaxError: invalid syntax

Beseda class je rezervirana in ne more biti ime spremenljivke ali argumenta. Kadar želimo povpraševati po name ali class, jih moramo podati s slovarjem.

soup.find_all("a", {"name": True})[:10]
[<a name="a32541">Baader, Bernhard, 1790-1859</a>,
 <a name="a52090">Baadsgaard, Anna, 1865-1954</a>,
 <a name="a26759">Baarslag, C.</a>,
 <a name="a47041">Bååth, A. U. (Albert Ulrik), 1853-1912</a>,
 <a name="a556">Babbage, Charles, 1791-1871</a>,
 <a name="a2498">Babbitt, Ellen C.</a>,
 <a name="a56365">Babbitt, George Franklin, 1848-1926</a>,
 <a name="a51605">Babbitt, Harold E. (Harold Eaton), 1888-1970</a>,
 <a name="a45766">Babbitt, Irving, 1865-1933</a>,
 <a name="a42830">Babbitt, Katharine</a>]

Pogojnega stavka pri sestavljanju slovarja se lahko znebimo.

authors = {}
for link in soup.find_all("a", {"name": True}):
    authors[link.string] = link.attrs["name"]

Ali pa napišemo kar

authors = {link.string: link.attrs["name"] for link in soup.find_all("a", {"name": True})}

Pridobivanje podatkov s spletnih strani je lahko enostavno, če znamo in če so prijazno sestavljene...

Še malo branja

Zdaj si zadajmo težjo nalogo. Dobimo avtorja ali, da bo preprosteje, kar njegovo interno oznako - za Babbaga, recimo a556. Naloga je poiskati vsa njegova dela, objavljena na Gutenbergu.

Če jih vidimo na spletni strani, so očitno v dokumentu, le do njih moramo znati priti. Vrnemo se v brskalnik, v spodnjem delu odpremo elemente pod h2.

Vidimo strukturo? Začnemo pri a. Njegov oče je h2. Brat h2-ja je ul. ul-jevi otroci vsebujejo posamična dela.

Najprej torej zgrabimo Babbaga. Ker vemo, da je na strani le en a, katerega name je enak a556, namesto find_all (čez katerega bi morali z zanko), pokličemo kar find, ki vrne prvi - in v tem primeru edini - element, ki ustreza podanim argumentom.

link = soup.find("a", {"name": "a556"})

link
<a name="a556">Babbage, Charles, 1791-1871</a>

Njegov oče je shranjen v parent.

link.parent
<h2><a name="a556">Babbage, Charles, 1791-1871</a> <a href="#a556" title="Link to this author">¶</a></h2>

Njegov brat je next_sibling.

link.parent.next_sibling
'\n'

Eh. Ker je v HTMLju tu vmes znak za prazno vrstico (isto smo videvali tudi v XML, nismo?), je brat ta prazna vrstica. Najbrž bi bilo potrebno vzeti naslednjega brata?

link.parent.next_sibling.next_sibling
<ul>
<li class="pgdbxlink"><a href="https://en.wikipedia.org/wiki/Charles_Babbage">en.wikipedia</a></li>
<li class="pgdbetext"><a href="/ebooks/71292">The calculating engine</a> (English) (as Author)</li>
<li class="pgdbetext"><a href="/ebooks/4238">On the Economy of Machinery and Manufactures</a> (English) (as Author)</li>
<li class="pgdbetext"><a href="/ebooks/57532">Passages from the Life of a Philosopher</a> (English) (as Author)</li>
<li class="pgdbetext"><a href="/ebooks/1216">Reflections on the Decline of Science in England, and on Some of Its Causes</a> (English) (as Author)</li>
</ul>

Da. Lahko pa smo previdni in se premaknemo za toliko bratov, dokler ne pridemo do ul.

ul = link.parent.next_sibling
while ul.name != "ul":
    ul = ul.next_sibling

Tako smo mimogrede spoznali še en atribut: name. Vsak element ima name, ki vsebuje ime značke. link, recimo, je a.

link.name
'a'

Tu gremo naprej po bratih, dokler ne pridemo do ul. (Če bi šlo zares, bi bil vaš izkušeni profesor, ki je doživel že veliko hudega s stranih postavljalcev spletnih strani še malo bolj previden in bi, za začetek, preverjal, da ul.name slučajno ni že h2, kar bi pomenilo, da smo že pri naslednjem avtorju in da ta, ki ga preiskujemo, nima objavljenih nobenih del.)

Sicer pa je iskanje najbližjega brata z določeno značko pogosta reč, zato lahko gornjo zanko nadomestimo s preprostim klicem find_next_sibling:

ul = link.parent.find_next_sibling("ul")
ul
<ul>
<li class="pgdbxlink"><a href="https://en.wikipedia.org/wiki/Charles_Babbage">en.wikipedia</a></li>
<li class="pgdbetext"><a href="/ebooks/71292">The calculating engine</a> (English) (as Author)</li>
<li class="pgdbetext"><a href="/ebooks/4238">On the Economy of Machinery and Manufactures</a> (English) (as Author)</li>
<li class="pgdbetext"><a href="/ebooks/57532">Passages from the Life of a Philosopher</a> (English) (as Author)</li>
<li class="pgdbetext"><a href="/ebooks/1216">Reflections on the Decline of Science in England, and on Some of Its Causes</a> (English) (as Author)</li>
</ul>

Če želimo iskati nazaj, po prejšnjih bratih, kličemo find_prev_sibling.

Po tej ali po oni poti torej pridemo do ul z deli Babbagea.

Dela so znotraj elementov li. li vsebuje a in nizi znotraj tega so dela tega avtorja.

for li in ul.find_all("li"):
    print(li.find("a").string)
en.wikipedia
The calculating engine
On the Economy of Machinery and Manufactures
Passages from the Life of a Philosopher
Reflections on the Decline of Science in England, and on Some of Its Causes

HTML v resnici

Tole je bil, jasno, šolski primer. Gutenbergove strani so prijazno preproste. V resnici imamo lahko z branjem veliko več zafrkavanja. Idejo pa smo videli.

Regularni izrazi

Imena avtorjev vsebujejo tudi letnici rojstva in smrti. Vendar ne vsa. Kako izvleči letnico, da bi jo imeli shranjeno in bi z njo kaj koristnega počeli? Ali pa jo, recimo, izpustili ob izpisu.

Verjetno bi se dalo kaj načarati s split-om, vendar bomo tu pokazali drug, močnejši pristop. Letnica ima določeno obliko, vzorec, ki ga je preprosto opisati: vsebuje nekaj števk, nato minus in še nekaj števk. "Nekaj" je lahko tudi 0, če letnica ni znana. Če bi bil natančni, bi dodali še, da so števke največ štiri, vendar ta omejitev najbrž ne bo potrebna.

Da bi bil primer primerno zahteven, lahko števkam sledi še vprašaj, če glede letnice nismo prepričani in je bil možakar (ali možakarica) morda ojen ali umrl tudi kdaj prej ali kasneje.

Vzorec te oblike opišemo z regularnim izrazom \d*\??-\d*\??.

Res. Takole iz "Tannenbaum, Samuel A. (Samuel Aaron), 1874?-1948" izvlečemo, kdaj se je rodil in umrl Tannenbaum

import re

re.search(r"\d*\??-\d*\??", "Tannenbaum, Samuel A. (Samuel Aaron), 1874?-1948").group()
'1874?-1948'

Če čmo, pa lahko dobimo tudi vsako številko posebej.

rojstvo, smrt = re.search(r"(\d*)\??-(\d*)\??", "Tannenbaum, Samuel A. (Samuel Aaron), 1874?-1948").groups()
rojstvo
'1874'
smrt
'1948'

Je bil teaser v redu? Nas zanima? Potemtakem moramo najprej spoznati sintakso regularnih izrazov, potem pa še funkcije v Pythonu za delo z njimi.

Sintaksa regularnih izrazov

Kar se bomo naučili tu, velja v splošnem, ne le v Pythonu. Regularni izraz so jezik za opis vzorcev, ki ga poznajo vsi splošno uporabni programski jeziki, z regularnimi izrazi pa lahko iščemo tudi po besedilih v urejevalnikih besedil.

Regularni izrazi so sestavljeni iz znakov, ki jih želimo poiskati (v gornjem primeru je bil to samo -) in znakov s posebnim pomenom. Tule je (nepopoln) seznam:

Znaki imajo tudi negacije: \W je karkoli, kar ni črka ali števka, \D je karkoli, kar ni števka in \S je karkoli, kar ni prazen prostor.

Poglejmo nekaj preprostih primerov

Očitno sta tako . kot \w preveč liberalna: dobro bi bilo, če bi lahko našteli znake, ki jih dovolimo. Za l.pa bi lahko dovolili e, i, o in u. Kadar želimo našteti dovoljene znake, jih zapremo v oglate oklepaje:

Če bi takoj za [ dali znak ^, bi regularni izraz lovil vse razen znakov v oklepajih. Torej črke in števke, ki niso samoglasniki.

Kaj pa, lip? Lahko povemo, da je nek znak lahko prisoten ali pa tudi ne?

Par primerov:

Vsi trije, *, + in ? se ne nanašajo nujno pa črko.

Z okroglimi oklepaji lahko združujemo dele regularnega izraza v skupine.

Kaj pa, kadar želimo, da izraz vsebuje piko? Torej zares piko, ne karkoli. Kako povemo, da s piko mislimo piko? Ali z vprašajem vprašaj? Z oklepajem oklepaj? Preden postavimo vzvratno poševnico.

Kaj pa, če želimo vzvratno poševnico? V tem primeru napišemo dve poševnici. To že obvladamo - enako je bilo, če je kak trmoglav uporabnik Windowsov na vsak način hotel uporabljati vzvratne poševnice v opisih poti.

Zdaj se lahko vrnemo k regularnemu izrazu za letnice: \d*\??-\d*\??.

Regularni izrazi v Pythonu

findall

Začnimo z nizom.

s = "Pred lipo je stala lepa lopa. Z lupo bi lahko iskal lepotne napake, a še tak lopov jih zlepa ne bi našel."

Vse lope bi se lahko lotili iskati z

import re

re.findall("lop.", s)
['lopa', 'lopo']

Lahko bi iskali tudi lepa (lepo, lepi...).

re.findall("lep.", s)
['lepa', 'lepo', 'lepa']
re.findall("\Wlop.\W", s)
<>:1: SyntaxWarning: invalid escape sequence '\W'
<>:1: SyntaxWarning: invalid escape sequence '\W'
/var/folders/2y/4j70c4q568l1j4lb6g1r0fk00000gn/T/ipykernel_90154/909440350.py:1: SyntaxWarning: invalid escape sequence '\W'
  re.findall("\Wlop.\W", s)
[' lopa.']
re.findall("\Wlep.\W", s)
<>:1: SyntaxWarning: invalid escape sequence '\W'
<>:1: SyntaxWarning: invalid escape sequence '\W'
/var/folders/2y/4j70c4q568l1j4lb6g1r0fk00000gn/T/ipykernel_90154/477483981.py:1: SyntaxWarning: invalid escape sequence '\W'
  re.findall("\Wlep.\W", s)
[' lepa ']

Znebili smo se lopova, dobili pa neke presledke in pike. Pike? A ni mišljeno, da pika ni pika, temveč ... Ja, brez skrbi. Tista pika v vzorcu se je ujela z a-jem. \W pa s piko v nizu. Tako kot presledka. Ker smo rekli, da mora vzorec vsebovati tudi kaj, kar ni črka, smo pač dobili še to.

Zdaj nas čaka kup detajlov. Prvi: bojte se vzvratnih poševnic. Vzvratne poševnice imajo svoj pomen. Vemo, \n je znak za novo vrstico. \W na našo srečo nima posebnega pomena, sicer že gornje ne bi delalo.

Vsakič, ko v nizu v Pythonu uporabljamo vzvratne poševnice in hočemo dobiti le vzvratne poševnice, jih moramo podvojiti. Torej ne \Wlep.\W temveč \\Wlep.\\W. V regularnih izrazih to postane zoprno, ker moramo včasih napisati dvojne poševnice v izrazu (glej zgoraj) in ko vsako podvojimo, dobimo četverne poševnice. Zato bomo regularne izraze raje pisali z r-nizi. Če pred narekovaj dopišemo r, bodo vzvratne poševnice samo vzvratne poševnice. (Črka r tu ne pomeni regular expression temveč raw string; takšne nize lahko uporabljamo tudi drugje, ne le v regularnih izrazih.) Pravilno je torej

re.findall(r"\Wlep.\W", s)
[' lepa ']

Tu ni razlike, v splošnem pa bo.

Kaj pa bomo storili s presledki? Preden se jih lotimo, opozorimo še na nekaj:

s2 = "Lepa lopa je stala pod lipo."

re.findall(r"\Wlep.\W", s2)
[]

Besede "Lepa" na začetku ni ujel, najprej, zato, ker je napisana z veliko začetnico. Funkciji re.findall lahko dodamo zastavice, s katerimi spremenimo njeno delovanje in ena od njih je re.IGNORECASE.

re.findall(r"^\Wlep.\W", s2, re.IGNORECASE)
[]

Še vedno ni Lepa. Zdaj, očitno, zato, ker pred besedo ni nečesa, kar ni črka - preprosto zato, ker pred njo sploh ni ničesar. Vzorec bo potrebno preoblikovati tako, da bomo dovolili, da je pred besedo bodisi nečrka bodisi začetek niza.

Da ne doktoriramo iz vzorcev, preprosto prilepimo presledek na začetek in konec niza.

re.findall(r"\Wlep.\W", " " + s2 + " ", re.IGNORECASE)
[' Lepa ']

Zdaj pa se znebimo presledkov v najdenem nizu: če vzorec vsebuje skupino, findall ne bo vrnil celotnega podniza temveč le, kar je v skupini.

re.findall(r"\W(lep.)\W", s)
['lepa']

Za konec polovimo še vse, kar je lepo lupa ali lupa.

re.findall(r"\W(l[eiou]p[aieo])\W", s)
['lipo', 'lepa', 'lupo']

Zdaj pa poskusimo tole: radi bi vse besede, ki se začnejo z lep-, lip-, lop- ali lup-.

re.findall(r"\W(l[eiou]p\w*)", s)
['lipo', 'lepa', 'lopa', 'lupo', 'lepotne', 'lopov']

Vzorec je zdaj takšen, da se mora začeti z nečem, kar ni črka (vendar to ni del skupine), sledi l, nato e, i, o ali u, potem p in potem poljubno število (lahko tudi 0) črk.

Kaj pa besede, ki vsebujejo l[eiou]p, spredaj in zadaj pa so lahko poljubne druge črke?

re.findall(r"\w*l[eiou]p\w*", s)
['lipo', 'lepa', 'lopa', 'lupo', 'lepotne', 'lopov', 'zlepa']

Požrešni operatorji

Glede tega vas je potrebno posvariti: kaj vrne l.*pa? Nepričakovano, tole: POVEJ za *?

re.findall(r"l.*pa", s)
['lipo je stala lepa lopa. Z lupo bi lahko iskal lepotne napake, a še tak lopov jih zlepa']

Python ima prav. Gre za niz, ki se začne z l in konča s pa. Najbrž pa smo pričakovali krajšega, ne?

Operatorja * in + sta požrešna: vzameta, kolikor je mogoče - vendar seveda tako, da je na konec še vedno mogoče dodati pa (ali karkoli že). Pogosto si predstavljamo, da bosta vzela najkrajši možni niz. To dosežemo tako, da za * oziroma + dodamo vprašaj. *? in +? sta torej nepožrešni različici zvezdice in plusa.

re.findall(r"l.*?pa", s)
['lipo je stala lepa',
 'lopa',
 'lupo bi lahko iskal lepotne napa',
 'lopov jih zlepa']

To seveda spet ni skladno s pričakovanji, vendar vidimo razliko: začne pri l in potem doda, kolikor je treba, da prida do naslednjega pa. Rezultat je sicer neželen, vendar vsaj zabaven. :)

Gornjemu bi se seveda izognili, če bi .*? zamenjali z \w*:

re.findall(r"l\w*pa", s)
['lepa', 'lopa', 'lepa']

Mimogrede pokažimo še, kako iz besedila izvlečemo vse besede (nekaj takega, vendar na bolj zapleten način, je zahtevala domača naloga Miklavževa pisma).

print(re.findall(r"\w+", s))
['Pred', 'lipo', 'je', 'stala', 'lepa', 'lopa', 'Z', 'lupo', 'bi', 'lahko', 'iskal', 'lepotne', 'napake', 'a', 'še', 'tak', 'lopov', 'jih', 'zlepa', 'ne', 'bi', 'našel']

Regularni izrazi imajo še veliko različnih opcij in trikov, zapletov in rešitev. V tem kratkem uvodu pač ne moremo našteti vseh; to bi v teoriji zahtevalo več predavanj, v praksi pa predvsem veliko izkušenj.

search in match (in finditer)

Doslej smo se ukvarjali le z vzorci in iskanjem podnizov, ki jim ustrezajo. Z regularni izrazi v Pythonu je možno početi še veliko več. Vzorci bodo pogosto sestavljeni iz več delov, skupin, in običajno nas bo zanimala njihova vsebina.

Spomnimo se izraza, s katerim smo iskali letnice rojstev in smrti, \d*\??-\d*\??. Ta ima očitno dva dela, letnico rojstva in letnico smrti. Lahko ju tudi uradno označimo kot skupini.

author = "Tannenbaum, Samuel A. (Samuel Aaron), 1874?-1948"

re.findall(r"(\d*)(\??)-(\d*)(\??)", author)
[('1874', '?', '1948', '')]

Če vzorec vsebuje več skupin, findall ne vrne seznama nizov temveč seznam terk. To bi bilo dovolj, da bi lahko razbrali letnico rojstva in smrti in - ker smo zvito zaprli v svojo skupino še vprašaj, namesto da bi ga pridružili števkam - imamo še ločen indikator, ki pove, ali je letnica zanesljiva ali ne.

Kaj, če bi hoteli spremeniti izpis - recimo odstraniti letnice iz imena avtorja? findall nam žal pove le, kaj je našel, ne pa tudi kje. Po drugi strani pa: findall vrne seznam, čeprav tule prav dobro vemo, da bo ta seznam vseboval kvečjemu en element.

Za take primere sta uporabnejši metodi search in match.

re.search(r"(\d*)(\??)-(\d*)(\??)", author)
<re.Match object; span=(38, 48), match='1874?-1948'>
re.match(r"(\d*)(\??)-(\d*)(\??)", author)

Tule, očitno, bolj search. :) Razlika je v tem, da match zahteva, da se vzorec pojavi na začetku niza, search pa ne.

Rezultat, ki ga vrne search, je re.Match: objekt, ki vsebuje več podatkov o tem, kaj je ujel vzorec.

mo = re.search(r"(\d*)(\??)-(\d*)(\??)", author)

Najprej: re.search lahko vrne None, če ne najde ničesar. Klicu funkcije bo zato tipično sledil if, ki bo poskrbel za ta scenarij.

Metoda group vrne vsebino posamezne skupine.

mo.group(1)
'1874'
mo.group(3)
'1948'

Ali več skupin.

mo.group(1, 3)
('1874', '1948')

Skupine začnemo šteti z 1, ker 0 predstavlja celoten ujeti podniz.

mo.group(0)
'1874?-1948'

Ker je to natančno tisto, kar običajno potrebujemo, je 0 tudi privzeta vrednost. Celoten niz torej dobimo z

mo.group()
'1874?-1948'

Vse podskupine - tisto, kar bi vrnil findall - pa dobimo z groups.

mo.groups()
('1874', '?', '1948', '')

Ker je skupin lahko veliko in jih je zoprno šteti, jih lahko tudi poimenujemo. To sicer zaplete izraz, a poenostavi delo z njim. Skupine namreč poimenujemo tako, da (...) zamenjamo z (?P<ime>...).

mo = re.search(r"(?P<born>\d*)(?P<bcertain>\??)-(?P<died>\d*)(?P<dcertain>\??)", author)

Zdaj lahko skupine naslavljamo z imeni, ne le indeksi.

mo.group("born")
'1874'
mo.group("died")
'1948'

Ali pa dobimo kar slovar skupin.

mo.groupdict()
{'born': '1874', 'bcertain': '?', 'died': '1948', 'dcertain': ''}

Za skupino lahko izvemo, kje v izvirnem nizu se začne oziroma konča. Ali oboje.

mo.start()
38
mo.end()
48
mo.span()
(38, 48)

Dodamo lahko indeks ali ime skupine.

mo.span("born")
(38, 42)

Vzemimo zdaj našega Tannenbauma, shranimo letnici njegovega rojstva in smrti v ločeni spremenljivki ter ju odstranimo iz niza, tako da bo v njem ostalo le ime avtorja.

author = "Tannenbaum, Samuel A. (Samuel Aaron), 1874?-1948"

mo = re.search(r", (?P<born>\d*)(?P<bcertain>\??)-(?P<died>\d*)(?P<dcertain>\??)", author)

if mo:
    born, died = mo.group("born", "died")
    author = author[:mo.start()]
else:
    born = died = ""
    
author
'Tannenbaum, Samuel A. (Samuel Aaron)'
born
'1874'
died
'1948'

sub in split

Nizi imajo metodo replace, s katero zamenjamo določen podniz z drugim. Regularnih izrazi imajo metodo sub, ki počne isto, le da niz, ki ga je potrebno zamenjati, podamo z regularnim izrazom.

s
'Pred lipo je stala lepa lopa. Z lupo bi lahko iskal lepotne napake, a še tak lopov jih zlepa ne bi našel.'
re.sub("l.p.", "hišo", s)
'Pred hišo je stala hišo hišo. Z hišo bi lahko iskal hišotne napake, a še tak hišov jih zhišo ne bi našel.'

To je seveda narobe. Lipo zamenjajmo s hišo, lepa pa bi bilo treba očitno zamenjati s heša. Storimo tako.

re.sub("l(.)p(.)", r"h\g<1>š\g<2>", s)
'Pred hišo je stala heša hoša. Z hušo bi lahko iskal hešotne napake, a še tak hošov jih zheša ne bi našel.'

Preprosto: v vzorcu smo označili skupine in se v zamenjavi sklicali nanje.

Kaj pa tole: imamo niz, ki vsebuje neka števila in vsa bi radi povečali za 1. To zahteva nekaj računanja, zato bo potrebno napisati funkcijo.

def increase1(mo):
    return str(int(mo.group()) + 1)
re.sub(r"\d+", increase1, "Vzamemo 5 jajc in 6 litrov vode, pomešamo in dodamo 200 g sladkorja.")
'Vzamemo 6 jajc in 7 litrov vode, pomešamo in dodamo 201 g sladkorja.'

In za konec še split. Niz ima metodo split, ki prejme ločilo, po katerem naj deli niz. Ločilo je vedno le niz. Regularni izrazi imajo metodo split, ki prejme regularni izraz. Razbijmo besedo glede na samoglasnike.

re.split("[aeiou]", "samoglasniki")
['s', 'm', 'gl', 'sn', 'k', '']
re.split("(?:[aeiou])", "samoglasniki")
['s', 'm', 'gl', 'sn', 'k', '']

(Medklic: če hočemo dobiti zloge - v smislu kombinacij črk, ki se končajo s samoglasnikom, potem to ni split temveč findall:

re.findall("[^aeiou]*[aeiou]", "samoglasniki")
['sa', 'mo', 'gla', 'sni', 'ki']

Konec medklica.)

Zajem podatkov iz besedil

Na tem predavanju smo se bežno dotaknili teme, ki zahteva veliko znanja in izkušenj ter še več potrpljenja: pridobivanja podatkov iz dokumentov, ki v osnovi niso bili namenjeni temu, da bi iz njih (avtomatsko) pridobivali podatke. HTML so v osnovi namenjeni branju, prosta besedila pa sploh. Ker so strani HTML navadno oblikovane in ker so pogosto sestavljene avtomatsko (sploh, če podatki prihajajo iz baze), smemo upati, da bo oblikovanje dovolj pregledno in konsistentno, da bo mogoče na podlagi značk prepoznati dele, ki vsebujejo podatke, ki jih iščemo. Znotraj njih si bomo pogosto lahko pomagali z regularnimi izrazi.

Stran, ki smo jo videli tu, je preprosta. V praksi ne bo vedno tako. In, še huje, v praksi se strani spreminjajo in če bomo mukoma pripravili program za zajem podatkov z neke strani, nam nihče ne garantira, da bo že prihodnji teden vse drugače in začeti bo potrebno znova. Kadar je mogoče priti do podatkov v berljivi obliki na kak drug način, je to vedno boljša opcija. Sicer pa - veliko sreče. :)