Computer Keyboard - Głównie JavaScript

Głównie JavaScript

rss feed icon

by

Wyszukiwarka plików HTML w PHP i SQLite

Szkło powiększające na książce

W tym wpisie przedstawię jak dodać wyszukiwarkę plików statycznych, napisaną w PHP, za pomocą Pythona oraz SQLite. Ja używam systemu Jekyll, ale statycznych generatorów stron (ang. Static Site Generators) jest cała masa rozwiązanie to powinno działać z każdym z nich. O ile serwer, na którym stoi obsługuje PHP. Nie powinno być też problemu, przepisanie skryptu PHP do innego języka np. Python, Node.js czy Ruby.

Pierwszą rzeczą jaką zrobiłem było Google, nie znalazłem nic ciekawego. Następnym krokiem była próba dodania wyszukiwarka za pomocą Google Custom Search. Za pomocą ich API dodałem wyszukiwarkę do swojej strony. Ale wyniki nie były zadowalające, dostałem masę reklam i wyniki z sub-domen, próbowałem w opcjach filtrować, ale się nie udało.

Dlatego postanowiłem sam napisać wyszukiwarkę. Pierwszy skrypt PHP przeszukiwał drzewo katalogów w poszukiwaniu plików HTML, ale był bardzo wolny. Pomyślałem więc, że można by utworzyć bazę danych SQLite, wrzucić do niej sam tekst ze strony plus url oraz tytuł strony i potem w php przeszukiwać tą bazę, co powinno być szybsze.

Postanowiłem napisać skrypt w Pythonie. Aby wyciągnąć dane z HTML użyłem biblioteki Beautiful Soup 4, najpierw musiałem ją zainstalować. Użyłem pip.

pip install beautifulsoup4
pip install html5lib

Bazując na wpisie Extracting text from HTML in Python: a very fast approach napisałem własną funkcje:

def get_data(html):
    """return dictionary with title url and content of the blog post"""
    tree = BeautifulSoup(html, 'html5lib')

    body = tree.body
    if body is None:
        return None

    for tag in body.select('script'):
        tag.decompose()
    for tag in body.select('style'):
        tag.decompose()
    for tag in body.select('figure'):
        tag.decompose()

    text = tree.findAll("div", {"class": "body"})
    if len(text) > 0:
      text = text[0].get_text(separator='\n')
    else:
      text = None
    title = tree.findAll("h2", {"itemprop" : "title"})
    url = tree.findAll("link", {"rel": "canonical"})
    if len(title) > 0:
      title = title[0].get_text()
    else:
      title = None
    if len(url) > 0:
      url = url[0]['href']
    else:
      url = None
    result = {
      "title": title,
      "url": url,
      "text": text
    }
    return result

Na moim blogu użyłem tagu link do wskazania strony canonical, czyli tej oryginalnej, jest to wskazane z punktu widzenia SEO. Dlatego mogłem pobrać url strony bezpośrednio z HTML. Nagłówek strony znajdował się w tagu h2, z atrybutem itemprop, też dla wyszukiwarek. Aby pobrać zawartość strony musiałem lekko zmodyfikować html stron, musiałem dodać wrapper na samą zawartość, czyli <div class="body">. Bez tej zmiany, musiałbym indeksować też linki zobacz też, linki do Facebook-a, czy link do źródła strony.

Reszta skryptu wygląda tak, tak naprawdę pisałem powyższą funkcje jednocześnie z resztą kodu.

import os, sys, re, sqlite3
from bs4 import BeautifulSoup

if __name__ == '__main__':
  if len(sys.argv) == 2:
    db_file = 'index.db'
    # usunięcie starego pliku
    if os.path.exists(db_file):
      os.remove(db_file)
    conn = sqlite3.connect(db_file)
    c = conn.cursor()
    c.execute('CREATE TABLE page(title text, url text, content text)')
    # traversowanie drzewa katalogów
    for root, dirs, files in os.walk(sys.argv[1]):
      for name in files:
        if name.endswith(".html") and re.search(r"[/\\]20[0-9]{2}", root):
          fname = os.path.join(root, name)
          f = open(fname, "r")
          data = get_data(f.read())
          f.close()
          if data is not None:
            data = (data['title'], data['url'], data['text']
            c.execute('INSERT INTO page VALUES(?, ?, ?)', data))
            print "indexed %s" % data['url']
            sys.stdout.flush() # flush wyświetli text z print na terminalu
    conn.commit() # bez tego nie zapiszą się dane
    conn.close()

Wyrażenie re.search(r"[/\\]20[0-9]{2}", root) filtruje pliki, które nie zaczynają się od 20, czyli te, w których mam wpisy (np. 2018). /\\ jest po to, aby działało pod systemem Windows jak i Linux/MacOSX.

Mając plik bazy SQLite, mogłem napisać skrypt PHP, który by wyszukiwał i wyświetlał wyniki. Oto on

<?php

function mark($query, $str) {
    return preg_replace("%(" . $query . ")%i", '<mark>$1</mark>', $str);
}

if (isset($_GET['q'])) {
  $db = new PDO('sqlite:index.db');
  $stmt = $db->prepare('SELECT * FROM page WHERE content LIKE :var OR title LIKE :var');
  $wildcarded = '%'. $_GET['q'] .'%';
  $stmt->bindParam(':var', $wildcarded); // trzeba użyć zmiennej w tym miejscu
  $stmt->execute();
  $data = $stmt->fetchAll(PDO::FETCH_ASSOC);
  // bez tego nie można byłoby wyszukać %
  $query = str_replace("%", "\\%", preg_quote($_GET['q']));
  $re = "%(?>\S+\s*){0,10}(" . $query . ")\s*(?>\S+\s*){0,10}%i";
  if (count($data) == 0) {
    echo "<p>Brak wyników</p>";
  } else {
    foreach ($data as $row) {
      if (preg_match($re, $row['content'], $match)) {
        echo '<h3><a href="' . $row['url'] . '">' . mark($query, $row['title']) . '</a></h2>';
        // usunięcie zbędnych znaków interpunkcyjnych oraz białych znaków
        $text = trim($match[0], " \t\n\r\0\x0B,.{}()-");
        echo '<p>' . mark($query, $text) . '</p>';
      }
    }
  }
}

?>

Następnie należało opakować wyszukiwarkę, w taki sam layout jak cała strona. Okazało się, że Generator plików statycznych Jekyll, którego używam, nie ma problemów, aby wygenerować plik PHP tak samo jak pliki HTML.

Wystarczył poniższy kod:

---
layout: default
---
<section class="search">
  <div>
    <header>
       <h2>
          Wyszukanie
          "<?= isset($_GET['q']) ? strip_tags($_GET['q']) : '' ?>"
       </h2>
    </header>
<?php /* kod php */ ?>
  </div>
</section>

Ostatnia część to dodanie formularza w sidebarze:

<div id="search">
    <form action="https://jcubic.pl/search.php">
        <input name="q" placeholder="text do wyszukania"/>
        <input type="submit" value="wyszukaj"/>
    </form>
</div>

i jego ostylowanie:

#search button {
    background-color: #171f32;
    color: #E9E9E8;
    width: 90px;
}
#search input {
    border: 1px solid #171f32;
    /* 90 button width + 10 padding + 2 border input + 4 border button */
    width: calc(100% - 106px);
}
#search {
    margin-bottom: 20px;
}
#search input, #search button {
    padding: 3px 5px;
    font-size: 1em;
}

Dodałem też poprawki do wyników, aby wyglądały jak inne strony.

Jedyne z czym miałem problem to wcięcia w HTML. Inne strony są przepuszczane przez tidy html5, który wypluwa sformatowany kod HTML, o którego ciężko w narzędziu Jekyll. Można użyć tidy w php, ale niestety nie da rady, bo musiałbym mieć skrypt tag na początku aby włączyć buforowanie za pomocą funkcji ob_start() (może dodam ją jeszcze, w pliku Makefile, za pomocą sed-a).

I to tyle, możesz przetestować działanie skryptu na stronie, jeśli masz jakieś pytania, albo sugestie odnośnie tego rozwiązania, zostaw je w komentarzu. Kod możesz znaleźć na GitHubie:

Plik, z bazą danych SQLite, także jest w repozytorium.

źródło strony (aby zobaczyć kod na githubie musisz kliknąć przycisk raw)