]> BookStack Code Mirror - website/blob - search/webidx.pl
Added nd_pdo_mysql
[website] / search / webidx.pl
1 #!/usr/bin/perl
2
3 # This perl script builds an sqlite search index for this site.
4
5 # Taken from https://github.com/gbxyz/webidx/tree/main
6 # BSD 3-Clause License
7 # Copyright (c) 2024, Gavin Brown
8 # Full license: https://github.com/gbxyz/webidx/blob/a28a984d38fd546d1bec4d6a4a5a47ab86cb08f8/LICENSE
9
10 # Modifications have been made since taking a copy of the code
11 # to suit this particular website and use-case
12
13 # To use, needed to install the following packages (Tested on Fedora 39):
14 # perl-open perl-HTML-Parser perl-DBD-SQLite
15
16 use Cwd qw(abs_path);
17 use Getopt::Long qw(:config bundling auto_version auto_help);
18 use DBD::SQLite;
19 use DBI;
20 use File::Basename qw(basename);
21 use File::Glob qw(:bsd_glob);
22 use HTML::Parser;
23 use IPC::Open2;
24 use IO::File;
25 use List::Util qw(uniq none any);
26 use feature qw(say);
27 use open qw(:encoding(utf8));
28 use strict;
29 use utf8;
30 use vars qw($VERSION);
31
32 $VERSION = 0.02;
33
34 #
35 # parse command line options
36 #
37 my (@exclude, @excludePattern, $compress, $origin);
38 die() unless (GetOptions('exclude|x=s' => \@exclude, 'excludePattern|xP=s' => \@excludePattern, 'compress|z' => \$compress, 'origin|o=s' => \$origin));
39
40 @exclude = map { abs_path($_) } @exclude;
41
42 #
43 # determine the source directory and the database filename
44 #
45 my $dir = abs_path(shift(@ARGV) || '.');
46 my $dbfile = abs_path(shift(@ARGV) || $dir.'/webidx.db');
47
48 #
49 # initialise the database
50 #
51 unlink($dbfile) if (-e $dbfile);
52 my $db = DBI->connect('dbi:SQLite:dbname='.$dbfile, '', '', {
53     'PrintError' => 1,
54     'RaiseError' => 1,
55     'AutoCommit' => 0,
56 });
57
58 #
59 # a list of words we want to exclude
60 #
61 my @common = qw(be and of a in to it i for he on do at but from that not by or as can who get if my as up so me the are we was is you this with an when want our there has);
62
63 #
64 # this is a map of filename => page title
65 #
66 my $titles = {};
67
68 #
69 # this is map of word => page
70 #
71 my $index = {};
72
73 #
74 # scan the source directory
75 #
76
77 say 'scanning ', $dir;
78
79 scan_directory($dir);
80
81 #
82 # generate the database
83 #
84
85 say 'finished scan, generating index';
86
87 $db->do(qq{BEGIN});
88
89 $db->do(qq{CREATE TABLE `pages` (`id` INTEGER PRIMARY KEY, `url` TEXT, `title` TEXT)});
90 $db->do(qq{CREATE TABLE `words` (`id` INTEGER PRIMARY KEY, `word` TEXT)});
91 $db->do(qq{CREATE TABLE `index` (`id` INTEGER PRIMARY KEY, `word` INT, `page_id` INT)});
92
93 my $word_sth    = $db->prepare(qq{INSERT INTO `words` (`word`) VALUES (?)});
94 my $page_sth    = $db->prepare(qq{INSERT INTO `pages` (`url`, `title`) VALUES (?, ?)});
95 my $index_sth   = $db->prepare(qq{INSERT INTO `index` (`word`, `page_id`) VALUES (?, ?)});
96
97 my $word_ids = {};
98 my $page_ids = {};
99
100 #
101 # for each word...
102 #
103 foreach my $word (keys(%{$index})) {
104
105     #
106     # insert an entry into the words table (if one doesn't already exist)
107     #
108     if (!defined($word_ids->{$word})) {
109         $word_sth->execute($word);
110         $word_ids->{$word} = $db->last_insert_id;
111     }
112
113     #
114     # for each page...
115     #
116     foreach my $page (keys(%{$index->{$word}})) {
117         #
118         # clean up the page title by removing leading and trailing whitespace
119         #
120         my $title = $titles->{$page};
121         $title =~ s/^[ \s\t\r\n]+//g;
122         $title =~ s/[ \s\t\r\n]+$//g;
123         #
124         # remove any trailing "· BookStack"
125         #
126         $title =~ s/· BookStack$//;
127
128         #
129         # remove the directory
130         #
131         $page =~ s/^$dir//;
132
133         #
134         # prepend the origin
135         #
136         $page = $origin.$page if ($origin);
137
138         #
139         # Trim off the /index.html
140         #
141         $page =~ s/\/index\.html$//;
142
143         #
144         # insert an entry into the pages table (if one doesn't already exist)
145         #
146         if (!defined($page_ids->{$page})) {
147             $page_sth->execute($page, $title);
148             $page_ids->{$page} = $db->last_insert_id;
149         }
150
151         #
152         # insert an index entry
153         #
154         $index_sth->execute($word_ids->{$word}, $page_ids->{$page}) || die();
155     }
156 }
157
158 $db->do(qq{COMMIT});
159
160 $db->disconnect;
161
162 if ($compress) {
163     say 'compressing database...';
164     open2(undef, undef, qw(gzip -f -9), $dbfile);
165 }
166
167 say 'done';
168
169 exit;
170
171 #
172 # reads the contents of a directory: all HTML files are indexed, all directories
173 # are scanned recursively. symlinks to directories are *not* followed
174 #
175 sub scan_directory {
176     my $dir = shift;
177
178     foreach my $file (map { abs_path($_) } bsd_glob(sprintf('%s/*', $dir))) {
179         if (-d $file) {
180
181             next if (any { $file =~ m/\Q$_/i } @excludePattern);
182
183             #
184             # directory, scan it
185             #
186             scan_directory($file);
187
188         } elsif ($file =~ /\.html?$/i) {
189             #
190             # HTML file, index it
191             #
192             index_html($file);
193
194         }
195     }
196 }
197
198 #
199 # index an HTML file
200 #
201 sub index_html {
202     my $file = shift;
203
204     return if (any { $_ eq $file } @exclude) || (any { $file =~ m/\Q$_/i } @excludePattern);
205
206     my $currtag;
207     my $title;
208     my $text;
209     my $inmain = 0;
210     my $parser = HTML::Parser->new(
211         #
212         # text handler
213         #
214         'text_h' => [sub {
215             if ('title' eq $currtag) {
216                 #
217                 # <title> tag, which goes into the $titles hashref
218                 #
219                 $title = shift;
220
221             } elsif ($inmain) {
222                 #
223                 # everything else, which just gets appended to the $text string
224                 #
225                 $text .= " ".shift;
226
227             }
228         }, qq{dtext}],
229
230         #
231         # start tag handler
232         #
233         'start_h' => [sub {
234             $currtag = $_[0];
235             if ('main' eq $currtag) {
236                 $inmain = 1;
237             }
238
239             if ($inmain) {
240                 #
241                 # add the alt attributes of images, and any title attributes found
242                 #
243                 $text .= " ".$_[1]->{'alt'} if (lc('img') eq $_[0]);
244                 $text .= " ".$_[1]->{'title'} if (defined($_[1]->{'title'}));
245             }
246         }, qq{tag,attr}],
247
248         #
249         # end tag handler
250         #
251         'end_h' => [sub {
252             undef($currtag);
253             if ('main' eq $_[0]) {
254                 $inmain = 0;
255             }
256         }, qq{tag}],
257     );
258
259     $parser->unbroken_text(1);
260
261     #
262     # we expect these elements contain text we don't want to index
263     #
264     $parser->ignore_elements(qw(script style header nav footer svg));
265
266     #
267     # open the file, being careful to ensure it's treated as UTF-8
268     #
269     my $fh = IO::File->new($file);
270     $fh->binmode(qq{:utf8});
271
272     #
273     # parse
274     #
275     $parser->parse_file($fh);
276     $fh->close;
277
278     return if (!$text);
279
280     $titles->{$file} = $title;
281     my @words = grep { my $w = $_ ; none { $w eq $_ } @common } # filter out common words
282                 grep { /\w/ }                                   # filter out strings that don't contain at least one word character
283                 map {
284                     $_ =~ s/^[^\w]+//g;                         # remove leading non-word characters
285                     $_ =~ s/[^\w]+$//g;                         # remove trailing non-word characters
286                     $_;
287                 }
288                 split(/[\s\r\n]+/, lc($text));                  # split by whitespace
289
290     foreach my $word (@words) {
291         #
292         # increment the counter for this word/file
293         #
294         $index->{$word}->{$file}++;
295     }
296 }
297
298 =pod
299
300 =head1 SYNOPSIS
301
302     webidx [-x FILE [-x FILE2 [...]]] [--xP PATTERN [--xP PATTERN2 [...]]] [-o ORIGIN] [-z] [DIRECTORY] [DBFILE]
303
304 This will cause all HTML files in C<DIRECTORY> to be indexed, and the resulting database written to C<DBFILE>. The supported options are:
305
306 =over
307
308 =item * C<-x FILE> specifies a file to be excluded. May be specified multiple times.
309
310 =item * C<--xP PATTERN> specifies a pattern of folders and files to be excluded. May be specified multiple times.
311
312 =item * C<-o ORIGIN> specifies a base URL which will be prepended to the filenames (once C<DIRECTORY> has been removed).
313
314 =item C<-z> specifies that the database file should be compressed once generated. If specified, the database will be at C<DBFILE.gz>.
315
316 =item * C<DIRECTORY> is the directory to be indexed, defaults to the current working directory.
317
318 =item * C<DBFILE> is the location where the database should be written. if not specified, defaults to C<DIRECTORY/index.db>.
319
320 =back
321
322 =cut