2015. március 27., péntek

Google keresés kép alapján Python alól

Elég régóta létező funkció a Google képkeresőjében a Keresés kép alapján, tehát amikor belinkelsz vagy feltöltesz egy képet a Google-nek és az visszahányja hogy milyen weboldalakon található meg az a kép. A kereső sor jobb szélén, a kis fényképező ikonra kattintva lehet előhozni.

Még a múlt hétvégén alakult úgy hogy sok képet akartam visszaellenőrizni. Persze semmi se garantálja hogy attól hogy ha egy kép keresésére nincs eredmény akkor az a kép tényleg egyedi, de azért ahogy az embereket ismerem, nem az internet legsötétebb oldalairól vesznek elő képeket, ha identitást kell hazudni.

Egy rövidebb keresés után kiderült, hogy nincs publikus HTTP API amin keresztül egyszerűen lehetne kereséseket intézni, de semmi probléma, ott a HTML az lesz most az API.

Egy egyszerű kis generátor függvényt raktam össze a feladat elvégzésére, ami egy fájlt és betöltendő találati oldalak maximális számát várja, és visszatér a találatok feliratával és az URL-jével.

Függőségek

TL;DR pip install requests requests_toolbelt lxml cssselect

Sok scrapert írtam már, úgyhogy van egy jól bejáratott hálózati stackem ami úgy pumpálja ki a HTTP kéréseket, ahogy én akarom, de fájl feltöltést nem próbáltam még vele, illetve a képkereső form multipart/form-data formátumban várja az adatokat, tehát kellett volna valami serializálót írnom, amihez épp nem volt kedvem. Megnéztem hogy a nagyon népszerű requests library képes-e out-of-box a fenti formában POST-olni a kérésemet, de sajnos úgy láttam hogy nem, viszont request_toolbelt libraryben van olyan encoder ami képes erre. Ezek türkében úgy döntöttem, hogy akkor most nem szarakodok a saját stackemmel.

A hálózati részen kívül már csak egy HTML parserre van szükség. Én mindig az lxml-t használom. Régebben próbálkoztam a Beautiful Souppal, és azon kívül hogy vagy 7x tovább tart leírni a nevét mint azt hogy lxml, még lassú is volt, meg a CSS selectorokat se ismerte. Évekkel ezelőtt volt ez, azóta rá se néztem, lehet azóta már jobb a helyzet. Az külön felbasz hogy a /r/Python-ban állandóan a Beautiful Soup-os kódokat mutogatnak. Ok, aláírom hogy az lxml-t fordítani kell, dehát annyira nem kurva nehéz ám az. Az lxml-hez kell még a cssselect csomag, és akkor végre boldogan állhatunk neki a programozásnak.

Generátorok

Azért döntöttem úgy hogy generátor függvényt írok, mert több találati oldalt akarok majd végig iterálni. Nem szeretném viszont hogy 100 oldal végignézését végig kelljen várni. Helyette inkább szépen iterálgatom a generátort, és ha mondjuk baromi régóta csinálom már ezt, és semmi extra találat nincs, akkor abbahagyom az egészet.

Két ilyen generátor függvényt írtam. Kezdjük az egyszerűbbel. Ez az amelyik visszaadja a találatokat az oldalról:

def extract(soup):
    for elem in soup.cssselect('.rc h3 a'):
        yield {'text': elem.text_content(), 'url': elem.get('href')}

A függvény már egy lxml fát vár. Note: azért soup megmaradt a Beautiful Soup-ból.
A 2. sorban a .rc h3 a selectort kézzel raktam össze. Ennek a történetét most nem részletezem, külön bejegyzést lehetne írni arról, hogy hogy kell selectorokat írni.
A ciklus törzsében egyszerűen visszatérünk egy dict-el, benne a link szövegével és az URL-jével.

Most veszem csak észre, hogy igazából a visszatérés nem a legjobb kifejezés ebben az esetben, hisz nem return utasítás áll a függvény végén, hanem yield. Meg amúgy nem is igazán van értelme ezt a függvényt generátornak megtenni, elég lenne egy sima list-el visszatérni, viszont ki akartam próbálni egy új nyelvei elemet, amit a Python 3.3 vezetett be, és ez pont egy jó szituáció volt a próbára.

Ezt a függvényt most hagyjuk így, és lássuk azt, amelyik a tényleges feltöltést végzi:

import requests
from lxml.html import fromstring
from requests_toolbelt.multipart.encoder import MultipartEncoder

def reverse(fp, walk_limit=5):
    walked = 0
    user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.89 Safari/537.36'
    m = MultipartEncoder({'encoded_image': (fp.name, fp, 'image/jpeg'), 'image_url': None, 'image_content': None, 'filename': None, 'btnG': None})

    r = requests.post('http://www.google.hu/searchbyimage/upload', data=m, headers={'Content-Type': m.content_type, 'User-Agent': user_agent})
    soup = fromstring(r.text, base_url='http://www.google.hu')
    soup.make_links_absolute()
    yield from extract(soup)

    next = soup.cssselect('#pnnext')
    while walked < walk_limit and next:
        walked += 1

        url = next[0].get('href')
        r = requests.get(url, headers={'User-Agent': user_agent})
        soup = fromstring(r.text, base_url='http://www.google.hu')
        soup.make_links_absolute()

        next = soup.cssselect('#pnnext')
        yield from extract(soup)

A reverse() két paramért vár: egy fájl-szerű objektumot, és a bejárt találati oldalak limitjét.
Az első izgalmas rész a 4. sorban van. Itt jön létre az encoder, ami kap egy szép dict-et. Az itt megjelenő kulcs-érték párokat úgy kaptam, hogy figyeltem Chrome Development Tools Network fülén hogy mi megy ki a POST-ban.

A legizgalmasabb rész a 9. sorban van, a yield from extract(soup). Itt szépen delegáljuk az extract() generátort.

Delegáció nélkül ez így nézne ki:

for hit in extract(soup):
   yield hit

Vagy ha az extract() list-el térne vissza, és akkor yield extract(soup), de az olyan unalmas.

A 11. sorban megint egy kis selector van. Az oldal alján a ‘Következő’ feliratú link id-je a #pnnext, ezt is választja ki a metódus. Szerencsére a cssselect() üres tömbbel tér vissza ha nincs a selectornak megfelelő elem a levesben. Ezt a 12. sorban ki is használom, mivel a [] logikai értéke False. Nem úgy mint Javascriptben ahol az Isten se tudja hogy mi.
Ebben a 12. sorban még azt ellenőrzöm hogy kevesebb oldalt töltöttem-e be mint mint a walk_limit.
A 13. sorban gyors növelem is a betöltött oldalak számát, még mielőtt elfelejteném, és végignézném mind a 7,800,000,000 találatot. JA VÁRJ az amúgy se következik be olyan könnyen, mert laponként iterálgatunk. Köszi generátorok.

A 15. sortól gyakorlatilag minden úgy megy, mint a függvény elején az első oldal lekérésénél. Ez egy szép iteratív bejárás, ennyi bőven elég is ide.
A 20. sorban gyorsan, még mielőtt a 21. sorban delegálnánk a linkeket megjegyezzük a következő oldalra mutató elemet, ha van ilyen. De mellékesen, mivel a függvény ugye nem tér vissza, ez a két sor akár meg is cserélhetné egymást. úgy is működne.

Az egész kóceráj végén a ciklus szépen megy tovább addig, amíg a feltétel igaz ~

Próba

A Python.org oldaláról letöltöttem a logót onnan az oldal tetejéről (remélem nem haragudnak meg érte). Nézzük hogy megtaláljuk-e a képet.

from google_reverse import reverse
results = reverse(open('/tmp/python-logo.png', 'rb'))
next(results)
#>>> {'url': 'http://hu.wikipedia.org/wiki/Python_%28programoz%C3%A1si_nyelv%29', 'text': 'Python (programozási nyelv) – Wikipédia'}

Ez bizony megtalálta.

Az összes (5 oldalnyi) találat behúzásához csak iteráljuk végig:

from google_reverse import reverse
results = reverse(open('/tmp/python-logo.png', 'rb'))
for result in results:
    print(result)
#>>> {'url': 'http://hu.wikipedia.org/wiki/Python_%28programoz%C3%A1si_nyelv%29', 'text': 'Python (programozási nyelv) – Wikipédia'}
#>>>{'url': 'http://hu.metapedia.org/wiki/F%C3%A1jl:Python_logo.svg', 'text': 'Fájl:Python logo.svg - Metapedia'}
#>>> ...

És így tovább.

Figyelem: mivel képről (bináris adatról) van szó, ezért bináris olvasásra nyissuk meg a fájlt!

Ennyi mára

Huh, ez a bejegyzés még épp belefért ebbe a hónapba. Persze a havi 1 post még mindig szegényes, de még mindig jobb mint az évi 1.

A teljes kód megtekinthető ezen a Gist-en.

-slp