Wyszukiwarka w PHP
W tym artykule przyjrzymy się analizie słów używanych na stronie. Nie będzie to aż tak dogłębna analiza jaką robi zapewne np. google, ale na tyle zaawansowana by można było zbudować w jej oparciu wyszukiwarkę. Skupimy się na języku polskim, a do zapisywania słów skorzystamy z tablicy i słownika.
Skrypt będzie zliczał ilość wystąpień danego słowa (i jego odmian) i na tej podstawie ustalał kolejność w jakiej będzie pojawiał się dany artykuł.
Parsowanie strony
Strona zawiera wiele elementów – skrypty, linki, menu itp. Pełna analiza jest dosyć skomplikowana. I nie będziemy jej przeprowadzać. Jeżeli chcesz, możesz wykonać ją samodzielnie wg. np. poniższych wytycznych:
<h1>
– najwyższy nagłówek – tytuł strony – słowa kluczowe tutaj są najważniejsze<h2> <h3>
… – kolejne poziomy nagłówków reprezentujące poszczególne rozdziały<p>
– paragraf wewnątrz którego znajduje się treść
Sama treść jest zwykle oddzielona od reszty – znajduje się w dedykowanym elemencie <div>.
My wprawdzie przeprowadzimy parsowanie, ale skupimy się na konkretnych artykułach, a nie całej stronie.
Parsowanie artykułu
Parsowanie własnej całej strony internetowej nie ma w tym przypadku większego sensu. W WordPressie lepiej skorzystać z API do pobrania zawartości artykułów i tylko na nich się skupić. Posłużymy się tutaj przykładem opisanym w artykule o tworzeniu wtyczek w WordPress.
$posts = get_posts($params);
foreach ($posts as $post) {
// post znajduje się w $post->post_content
}
Jak już wspomniałem w atrybucie <h1>
znajduje się tytuł. Informacje w nim zawarte są najważniejsze (maja największą wagę). Istotne są również słowa kluczowe w atrybutach <h2>
, <h3>
itd. Podobnie ważne są wyrazy pogrubione poprzez <b>
lub <strong>
.
Ustalmy zatem wagi poszczególnych słów, które będą po prostu „mnożnikami” wyrazów np. jeżeli słowo „parsowanie” wystąpiło w tytule, który ma wagę 5 to będzie ono traktowane jak 5 wystąpień tego słowa.
h1
– waga 5h2
– waga 3h3
,h4
,h5
,h6
, pogrubienie – waga 2
Do parsowania dokumentu moglibyśmy użyć klasy DOMDocument
, ale w naszym przypadku wydaje się to być niepotrzebne. Dużo prościej jest użyć wyrażeń regularnych. Za ich pomocą wyciągniemy tekst znajdujący się pomiędzy wspomnianymi atrybutami i dodamy go do naszego tekstu odpowiednią ilość razy. Na końcu usuniemy wszystkie tagi w tekście.
preg_match_all ('/(?<=<h1>)(.*?)(?=<\/h1>)/', $post->post_content, $result); // waga 5
Powyższe zwróci do zmiennej $result
tablicę z wystąpieniami wspomnianych tagów. Następnie możemy ten tekst dodać do naszego źródłowego tekstu kilka razy.
function incraseWeight ($tags, &$text, $weight) {
if ($weight < 2) return $text;
foreach ($tags as $tag) {
$result = [];
preg_match_all ('/(?<=<' . $tag . '>)(.*?)(?=<\/' . $tag . '>)/', $text, $result);
if ( !empty($result) ) {
foreach ($result as $res) {
for ($i=1; $i<$weight; $i++) $text .= ' '.$res;
}
}
}
}
Powyższy kod wyszuka w zadanym tekście to co znajduje się między konkretnymi tagami i następnie doda tą zawartość na koniec tekstu tyle razy ile wynosi waga (-1, bo raz już jest).
Przed tą operacją warto zamienić wszystkie znaki na małe litery za pomocą strtolower
.
Po powieleniu tekstu, pozostaje usunąć tagi html za pomocą funkcji dostarczonej z WordPressem – wp_strip_all_tags
.
Wyrazy
Mając przygotowany tekst, możemy wyodrębnić poszczególne wyrazy za pomocą na przykład takiego kodu.
for ($i=0; $i<strlen($text); $i++) {
$word= '';
$chrnr = chr($i);
if ( (chrnr > 96 && chrnr < 123) || in_array($text[$i], ['ą', 'ć', 'ę', 'ł', 'ń', 'ó', 'ś', 'ź', 'ż']) ) {
$newtext .= $text [$i];
} elseif (in_array($text[$i], [' ',"\n"])) {
if (!empty($word)) $words[] = $word;
}
if (!empty($word)) $words[] = $word;
}
albo nieco prościej i zapewne szybciej
$return = [];
foreach (preg_split ('/((?![a-ząćęłńóśźż]).)*/', $text) as $row) {
$trimrow = trim($row);
if (!empty($trimrow)) $return[] = $row;
}
return $return;
W pierwszym kodzie sprawdzamy znak po znaku czy wystąpiła litera lub spacja / znak końca wiersza. W przypadku litery zapisujemy tekst w tablicy. Znak końca wiersza lub spacja oznacza zaś nowe słowo. W drugim kodzie rozdzielamy wyrazy na podstawie znaków, które nie są literami.
Słownik
Słownik języka polskiego ściągniemy ze strony https://sjp.pl/sl/po.phtml. Nas w tym przypadku interesuje lista słów z odmianami. Spójrzmy na wycinek słownika.
obwój, obwojach, obwojami, obwoje, obwojem, obwojom, obwojowi, obwojów, obwoju
oby, obym, obyś, obyście, obyśmy
obycie, obycia, obyciach, obyciami, obyciem, obyciom, obyciu, obyć
obyczaj, obyczai, obyczajach, obyczajami, obyczaje, obyczajem, obyczajom, obyczajowi, obyczajów, obyczaju
obyczajniej
obyczajność, obyczajności, obyczajnościach, obyczajnościami, obyczajnością, obyczajnościom
Łącznie ponad 230 tysięcy wierszy. Pierwszy wyraz w każdym zawiera formę podstawową i do takiej będziemy sprowadzać słowo, zanim zliczymy ilość wystąpień. Zapisanie wszystkich słów w bazie i wyszukiwanie ich byłoby czasochłonne i zasobożerne. Zauważ, że chociaż samych wierszy jest 240 tysięcy (co jeszcze ujdzie) to łącznie wyrazów będzie prawdopodobnie kilka lub kilkanaście milionów.
Tablice haszujące
idea w przypadku zapisu do bazy słów jest taka, by zamienić wyraz na liczbę i zapisać go w formie indeksu o maksymalnej wartości (np. 100 000). Ograniczy to znacznie ilość indeksów w głównej tabeli w bazie, niemniej niektóre wyrazy będą wpisane pod ten sam indeks (zauważ, że mamy 240 000 wyrazów w formie podstawowej, które chcemy upchać do 100 000 wierszy – w praktyce tych wyrazów będzie oczywiście więcej). Czasami wyszukiwanie zwróci błędny wynik. Im lepszy algorytm haszujący i im większa tablica tym większa dokładność.
Do trzymania tablic możemy wykorzystać np. SQLite – szczegóły opisałem w artykule Skrypt w PHP korzystający z bazy SQLite.
Do zamiany słowa na liczbę użyjemy algorytmu crc32
, który zwróci nam wartość liczbową (integer
). Następnie zmniejszymy ją za pomocą operacji modulo.
function index ($word, $max_val=100000) {
$nr = crc32($word);
return $nr % $max_val;
}
Tabela z odmianami
Tabela z odmianami będzie zawierała:
- hasz słowa odmienionego
- hasz słowa bez odmiany
Szukamy czy odmiana występuje w tablicy haszującej i jeżeli tak to bierzemy hasz słowa bez odmiany.
Słowo pomocnicze | Słowo główne |
62436 | 23423 |
73523 | 23423 |
23546 | 653 |
23 | 73458 |
Tabela z artykułami
Artykuły w WordPress są numerowane. Przykładowa tabela może wyglądać tak:
id słowa | id wpisu i waga |
57458 | 74 (2), 54 (1), 77 (1) |
53457 | 457 (1), 33 (2) |
85854 | 54 (3) |
Wtyczka do WordPress
Mamy już wszystkie potrzebne informacje, by stworzyć działającą wyszukiwarkę. Stwórzmy wtyczkę do WordPress na podstawie artykułu Kurs tworzenia wtyczek w WordPress. Skorzystamy zarówno z bazy SQLite jak i MySQL dostępnej najczęściej przy instalacji CMS-a. Korzystanie z bazy opisałem tutaj – Korzystanie z bazy danych WordPress.
W bazie SQLite będziemy przechowywać niezmienne dane dostarczane wraz z aplikacją – tabelę z odmianami. W bazie WordPressa zaś dane związane ze stroną, czyli tablicę haszującą z użytymi słowami. Takie rozwiązanie jest bezpieczniejsze, a ponadto MySQL ma nieco więcej możliwości, które tutaj wykorzystamy.
Baza z odmianami
Stwórzmy najpierw tabelę z odmianami na podstawie pobranego pliku. Tworzymy tabelę:
$hdcitpl = new SQLite3('dictpl.db');
$query = 'CREATE TABLE IF NOT EXISTS variations
(variation INT NOT NULL PRIMARY KEY,
word INT)';
$hdcitpl->exec ($query);
Dodajemy funkcję, która dodaje odmianę.
function addVariation (int $hashed_variation, int $hashed_word) {
global $hdcitpl;
$query = "INSERT INTO variations (variation, word)
VALUES ($hashed_variation, $hashed_word)";
$hdcitpl->exec ($query);
}
Oraz funkcje, która pobiera z pliku z odmianami linia po linii i zapisuje je do naszej bazy.
$fodm = new SplFileObject("odm.txt");
$counter = 0;
if ( file_exists ('progress.tmp') ) {
$counter = (int) file_get_contents ('progress.tmp');
echo "Kontynuuję od numeru $lastCounter\n";
} else {
file_put_contents('progress.tmp', $counter);
}
$fodm->seek($counter);
while ( !$fodm ->eof() ) {
$words = explode(',',$fodm->fgets());
$cwords = count($words);
if ($cwords > 1) {
$main = index ( trim($words[0]) );
for ($i = 1; $i < $cwords; $i++) {
$hword = index (trim ($words[$i]));
addVariation ($hword, $main);
}
}
$counter++;
file_put_contents ('progress.tmp', $counter);
}
$file = null;
unlink ('progress.tmp');
Powyższa funkcja korzysta z klasy SplFileObject
i metody fgets
do odczytu pliku linia po linii. W dalszej kolejności sprawdzamy ile jest słów w linii (musi być więcej niż jeden) i zapisujemy każde słowo w bazie danych. Jest to operacja jednorazowa, której celem jest przygotowanie bazy z odmianami.
Dodatkowo dodaliśmy zapisywanie miejsca w którym jesteśmy. Dzięki temu po odpaleniu ponownie skryptu będziemy kontynuowali dodawanie w miejscu w którym skończyliśmy.
Plik wtyczki
Mając przygotowaną bazę, możemy stworzyć wtyczkę do WordPressa. Najpierw oczywiście nagłówek.
/**
* Plugin Name: Wyszukiwarka oparta o tablice haszujące od eskim.pl
* Plugin URI: https://eskim.pl/korzystanie-z-bazy-danych-wordpress/
* Description: Przykład tworzenia wtyczki w WordPress na podstawie artykułu https://eskim.pl/wyszukiwarka-w-php/
* Version: 1.0
* Requires at least: 5.2
* Requires PHP: 5.6
* Author: Maciej Włodarczak
* Author URI: https://eskim.pl
* License: GPL v3 or later
* Text Domain: eskim_pl_hash_search
* Domain Path: /languages
*/
Tworzenie tabeli
Teraz przygotujmy tabelę w WordPress
if ( !function_exists('eskim_pl_hash_search_activation') ) :
function eskim_pl_hash_search_activation() {
global $wpdb;
$table_name = $wpdb->prefix . 'eskim_pl_words';
$sql = "CREATE TABLE IF NOT EXISTS $table_name (
id INT NOT NULL,
posts JSON,
PRIMARY KEY(id)
);";
dbDelta( $sql );
}
endif;
Funkcja haszująca
Przepiszmy jeszcze naszą funkcję haszującą.
if ( !function_exists('eskim_pl_hash_search_index') ) :
function eskim_pl_hash_search_index ($word, $max_val=1000000) {
$nr = crc32($word);
return $nr % $max_val;
}
endif;
Dodawanie artykułu i wagi
Tablica z identyfikatorami postów jest w formacie JSON. W ten sposób możemy w jednej kolumnie trzymać wiele różnych wartości i dodatkowo wykonywać na nich operacje bezpośrednio na bazie. Dane w kolumnie będą zwierać zestaw dwóch wartości – id wpisu oraz wagę, które możemy zapisać w formie klucz -> waga
. Stwórzmy funkcję dodającą wystąpienie słowa.
if ( !function_exists('eskim_pl_hash_search_add') ) :
function eskim_pl_hash_search_add (string $word, int $postId, int $weight = 1) {
global $wpdb;
$table_name = $wpdb->prefix . 'eskim_pl_words';
$hword = eskim_pl_hash_search_index ($word);
$encposts = json_encode ([$postId => $weight]);
$postIdPath = '$."'.$postId.'"';
$query = "
INSERT INTO $table_name (id, posts)
VALUES (4,'$encposts')
ON DUPLICATE KEY UPDATE
posts = JSON_SET ( posts, '$postIdPath', posts->'$postIdPath' + $weight )
";
return $wpdb->query ($query);
}
endif;
Funkcja dodaje słowo do tablicy haszującej oraz numer artykułu i jego wagę. Korzystamy tutaj z typu danych JSON, który jest dostępny w MySQL oraz funkcji JSON_SET
. JSON_SET
dodaje identyfikator wpisu i wagę, albo zwiększa wagę już istniejącego wpisu. W ten sposób możemy aktualizować dane analizując każde słowo w tekście po kolei. Ma to spory minus w postaci dużej ilości zapytań do bazy wynoszącej tyle ile jest słów w tekście.
Możemy więc stworzyć funkcję, która w parametrze przyjmuje tablicę do zapisania w formacie słowa i ilości wystąpień np.
[
'słowo' => 3,
'inne' => 5
]
Napiszmy ją
if ( !function_exists('eskim_pl_hash_search_add_array') ) :
function eskim_pl_hash_search_add_array (array $words, int $postId) {
global $wpdb;
if ( empty($words) ) return false;
foreach ($words as $word => $weight) {
if (!is_int ( $weight ) ) thrown new Exception ('Nieprawidłowe dane!');
}
$values = [];
$onupdate = [];
foreach ($words as $word => $weight) {
$hword = eskim_pl_hash_search_index ($word);
$encposts = json_encode ([$postId => $weight]);
$values[] = "($hword,'$encposts')";
}
$table_name = $wpdb->prefix . 'eskim_pl_words';
$postIdPath = '$."'.$postId.'"';
$impvalues = implode (',',$values);
$query = "
INSERT INTO $table_name (id, posts)
VALUES $impvalues AS tbl_ins
ON DUPLICATE KEY UPDATE
posts = JSON_SET(
$table_name.posts,
'$."' . $postId . '"',
tbl_ins.posts->'$postIdPath' + $table_name.posts->'$postIdPath'
)
";
return $wpdb->query ($query);
}
endif;
W powyższym przykładzie dodajemy słowa, jeżeli nie zostały jeszcze dodane. Taka konstrukcja działa od ósmej wersji MySQL – wcześniej używano VALUE do oznaczania wartości w przeszukiwanej kolumnie.
Usuwanie artykułów
Czasami zachodzi potrzeba ponownej analizy tekstu. Musimy mieć możliwość usunięcia z bazy identyfikatorów artykułów dla danego słowa oraz dla wszystkich artykułów.
-- usunięcie wszystkich artykułów
UPDATE $table_name SET posts = JSON_REMOVE (posts, '$."' . $postId . '"');
-- usunięcie tylko wybranych artykułów według słowa
UPDATE $table_name SET posts = JSON_REMOVE (posts, '$."' . $postId . '"') WHERE id = $id;
Analiza tekstu i wyciąganie danych
Teraz wystarczy sprawdzić zadany artykuł i wyciągnąć słowa z tekstu. Można to zrobić czytając znak po znaku, albo rozbić wszystko funkcją explode()
ze spacją jako parametrem. Przy drugim podejściu należy oczywiście użyć funkcji trim()
, aby usunąć zbędne znaki oraz sprawdzać czy nie ma pustej wartości przy przechodzeniu po zwróconej tablicy.
Na końcu trzeba po prostu wyciągać dane zapytaniem:
SELECT id, CAST (JSON_EXTRACT (posts, '$."' . $postId . '"') AS UNSIGNED) AS weight
FROM $table_name
WHERE JSON_CONTAINS_PATH (posts, 'one', '$."' . $postId . '"')
ORDER BY weight