recover gracefully after an XML parsing error
[migration-tools.git] / fingerprinter
1 #!/usr/bin/perl
2 use strict;
3 use warnings;
4 use open ':utf8';
5
6 use Getopt::Long;
7 use MARC::Batch;
8 use Unicode::Normalize;
9 use MARC::File::XML ( BinaryEncoding => 'utf-8' );
10 use Equinox::Migration::SubfieldMapper;
11
12 my $conf  = {}; # configuration hashref
13 my $count = 0; my $scount = 0;
14 my $start = time;
15 $| = 1;
16
17 initialize($conf);
18
19 open OF, '>', $conf->{output};
20 open XF, '>', $conf->{exception};
21
22 for my $file (@ARGV) {
23     print XF "Processing $file\n";
24     my $batch = undef; my $record = undef;
25
26     $batch = MARC::Batch->new($conf->{marctype}, $file);
27     $batch->strict_off();
28     $batch->warnings_off();
29
30     my $record;
31     while ( 1 ) {
32         eval { $record = $batch->next; };
33         if ($@) {
34             import MARC::File::XML;
35             print "bad record\n";
36             next;
37         }
38         last unless $record;
39         $count++; progress_ticker();
40         my $marc = undef;
41         unless ( defined $record )
42           { dump_exception($marc); next; }
43
44         my $id = $record->field($conf->{tag});
45         unless ($id) {
46             print XF "ERROR: Record $count in $file is missing a ",
47               $conf->{tag}, " field.\n", $record->as_formatted(), "\n=====\n";
48             next;
49         }
50
51         # populate and normalize marc
52         $marc = populate_marc($record, $id);
53         # check for manual exclusion
54         next if this_record_is_excluded($record, $marc);
55         normalize_marc($marc);
56         unless (marc_isvalid($marc))
57           { dump_exception($marc); next; }
58
59         # if everything looks good, score it and dump fingerprints
60         score_marc($marc, $record);
61         dump_fingerprints($marc);
62         $scount++; progress_ticker();
63     }
64 }
65
66 print "\nSuccessfully processed:\t$count\n" unless $conf->{quiet};
67
68 =head2 populate_marc
69
70 Constructs a hash containing the relevant MARC data for a record and
71 returns a reference to it.
72
73 =cut
74
75 sub populate_marc {
76     my ($record, $id) = @_;
77     my %marc = (); $marc{isbns} = [];
78
79     # id, stringified
80     $marc{id} = $id->as_string($conf->{subfield});
81
82     # record_type, bib_lvl
83     $marc{record_type} = substr($record->leader, 6, 1);
84     $marc{bib_lvl}     = substr($record->leader, 7, 1);
85
86     # date1, date2
87     my $my_008 = $record->field('008');
88     $marc{tag008} = $my_008->as_string() if ($my_008);
89     if (defined $marc{tag008}) {
90         unless (length $marc{tag008} == 40) {
91             $marc{tag008} = $marc{tag008} . ('|' x (40 - length($marc{tag008})));
92             print XF ">> Short 008 padded to ",length($marc{tag008})," at rec $count\n";
93         }
94         $marc{date1} = substr($marc{tag008},7,4) if ($marc{tag008});
95         $marc{date2} = substr($marc{tag008},11,4) if ($marc{tag008}); # UNUSED
96     }
97     unless ($marc{date1} and $marc{date1} =~ /\d{4}/) {
98         my $my_260 = $record->field('260');
99         if ($my_260 and $my_260->subfield('c')) {
100             my $date1 = $my_260->subfield('c');
101             $date1 =~ s/\D//g;
102             if (defined $date1 and $date1 =~ /\d{4}/) {
103                 $marc{date1} = $date1;
104                 $marc{fudgedate} = 1;
105                 print XF ">> using 260c as date1 at rec $count\n";
106             }
107         }
108     }
109
110     # item_form
111     if ( $marc{record_type} =~ /[gkroef]/ ) { # MAP, VIS
112         $marc{item_form} = substr($marc{tag008},29,1) if ($marc{tag008});
113     } else {
114         $marc{item_form} = substr($marc{tag008},23,1) if ($marc{tag008});
115     }
116
117     # isbns
118     my @isbns = $record->field('020') if $record->field('020');
119     push @isbns, $record->field('024') if $record->field('024');
120     for my $f ( @isbns ) {
121         push @{ $marc{isbns} }, $1 if ( defined $f->subfield('a') and
122                                         $f->subfield('a')=~/(\S+)/ );
123     }
124
125     # author
126     for my $rec_field (100, 110, 111) {
127         if ($record->field($rec_field)) {
128             $marc{author} = $record->field($rec_field)->subfield('a');
129             last;
130         }
131     }
132
133     # oclc
134     $marc{oclc} = [];
135     push @{ $marc{oclc} }, $record->field('001')->as_string()
136       if ($record->field('001') and $record->field('003') and
137           $record->field('003')->as_string() =~ /OCo{0,1}LC/);
138     for ($record->field('035')) {
139         my $oclc = $_->subfield('a');
140         push @{ $marc{oclc} }, $oclc
141           if (defined $oclc and $oclc =~ /\(OCoLC\)/ and $oclc =~/([0-9]+)/);
142     }
143
144     if ($record->field('999')) {
145         my $koha_bib_id = $record->field('999')->subfield('c');
146         $marc{koha_bib_id} = $koha_bib_id if defined $koha_bib_id and $koha_bib_id =~ /^\d+$/;
147     }
148
149     # "Accompanying material" and check for "copy" (300)
150     if ($record->field('300')) {
151         $marc{accomp} = $record->field('300')->subfield('e');
152         $marc{tag300a} = $record->field('300')->subfield('a');
153     }
154
155     # issn, lccn, title, desc, pages, pub, pubyear, edition
156     $marc{lccn} = $record->field('010')->subfield('a') if $record->field('010');
157     $marc{issn} = $record->field('022')->subfield('a') if $record->field('022');
158     $marc{desc} = $record->field('300')->subfield('a') if $record->field('300');
159     $marc{pages} = $1 if (defined $marc{desc} and $marc{desc} =~ /(\d+)/);
160     $marc{title} = $record->field('245')->subfield('a')
161       if $record->field('245');
162     $marc{edition} = $record->field('250')->subfield('a')
163       if $record->field('250');
164     if ($record->field('260')) {
165         $marc{publisher} = $record->field('260')->subfield('b');
166         $marc{pubyear} = $record->field('260')->subfield('c');
167         $marc{pubyear} =
168           (defined $marc{pubyear} and $marc{pubyear} =~ /(\d{4})/) ? $1 : '';
169     }
170     return \%marc;
171 }
172
173
174
175 =head2 normalize_marc
176
177 Gently massages your data.
178
179 =cut
180
181 sub normalize_marc {
182     my ($marc) = @_;
183
184     $marc->{record_type }= 'a' if ($marc->{record_type} eq ' ');
185     if ($marc->{title}) {
186         $marc->{title} = NFD($marc->{title});
187         $marc->{title} =~ s/[\x{80}-\x{ffff}]//go;
188         $marc->{title} = lc($marc->{title});
189         $marc->{title} =~ s/\W+$//go;
190     }
191     if ($marc->{author}) {
192         $marc->{author} = NFD($marc->{author});
193         $marc->{author} =~ s/[\x{80}-\x{ffff}]//go;
194         $marc->{author} = lc($marc->{author});
195         $marc->{author} =~ s/\W+$//go;
196         if ($marc->{author} =~ /^(\w+)/) {
197             $marc->{author} = $1;
198         }
199     }
200     if ($marc->{publisher}) {
201         $marc->{publisher} = NFD($marc->{publisher});
202         $marc->{publisher} =~ s/[\x{80}-\x{ffff}]//go;
203         $marc->{publisher} = lc($marc->{publisher});
204         $marc->{publisher} =~ s/\W+$//go;
205         if ($marc->{publisher} =~ /^(\w+)/) {
206             $marc->{publisher} = $1;
207         }
208     }
209     return $marc;
210 }
211
212
213
214 =head2 marc_isvalid
215
216 Checks MARC record to see if neccessary fingerprinting data is
217 available
218
219 =cut
220
221 sub marc_isvalid {
222     my ($marc) = @_;
223     return 1 if ($marc->{item_form} and ($marc->{date1} =~ /\d{4}/) and
224                  $marc->{record_type} and $marc->{bib_lvl} and $marc->{title});
225     return 0;
226 }
227
228
229 =head2 score_marc
230
231 Assign a score to the record based on various criteria.
232
233 Score is constructed by pushing elements onto a list, via a dispatch
234 table.  This allows order of fingerprints in the output file to be
235 varied.
236
237 =cut
238
239 sub score_marc {
240     my ($marc, $record) = @_;
241     my @score = ();
242     my $json = '{';
243
244     #----------------------------------
245     # static criteria scoring
246     #----------------------------------
247     $marc->{misc_score} = 999;
248     $marc->{age_score}  = 999999999999;
249
250     # -1 if 008 has been padded, -2 if it doesn't exist
251     if ($marc->{tag008})
252       { $marc->{misc_score}-- if ($marc->{tag008} =~ /\|$/) }
253     else
254       { $marc->{misc_score} -= 2 }
255     # -1 if date has been pulled from 260
256     $marc->{misc_score}-- if $marc->{fudgedate};
257     # -1 if this is a copy record
258     $marc->{misc_score}--
259       if (defined $marc->{tag300a} and $marc->{tag300a} =~ /copy/i);
260
261     # subtract record id if we want older records to win
262     #$marc->{age_score} -= $marc->{id} unless ($conf->{newwins});
263     # handle arbitrary adjustments
264     $marc->{age_score} = 1;
265     if ($conf->{'arbitrarily-lose-above'}) {
266         $marc->{age_score} = 0
267           if ($marc->{id} >= $conf->{'arbitrarily-lose-above'});
268     }
269     if ($conf->{'arbitrarily-lose-below'}) {
270         $marc->{age_score} = 0
271           if ($marc->{id} <= $conf->{'arbitrarily-lose-below'});
272     }
273
274     #----------------------------------
275     # dynamic calculated scoring
276     #----------------------------------
277     my %scores_code = (
278       oclc    => sub { return $marc->{oclc}[0] ? 1 : 0 },
279       dlc     => sub {
280           if ($record->field('040') and $record->field('040')->subfield('a'))
281             { return scalar($record->subfield( '040', 'a')) =~ /dlc/io ? 1 : 0 }
282           else { return 0 }
283       },
284       num_650 => sub {
285           if ($record->field('650')) {
286               # can't say "scalar $record->field('650')"; MARC::Record
287               # behaves differently in list/scalar contexts
288               my @tags = $record->field('650');
289               return sprintf("%04d", scalar @tags)
290           } else { return '0000' }
291       },
292       num_tags=> sub { return sprintf( '%04d', scalar( $record->fields ) ) },
293       enc_lvl => sub {
294         my $enc = substr($record->leader, 17, 1) || 'u';
295         my %levels = ( ' ' => 9, 1 => 8, 2 => 7,  3  => 6,  4  => 5, 5 => 4,
296                        6   => 3, 7 => 2, 8 => 1, 'u' => 0, 'z' => 0 );
297         return $levels{$enc} || 0;
298     }
299                       );
300
301     #----------------------------------
302     # assemble and store scores
303     #----------------------------------
304     for ( @{ $conf->{dyn_scores} } ) {
305         push @score, $scores_code{$_}->($marc, $record);
306         $json .= $_ . ':' . $score[-1] . ',';
307     }
308     $json .= 'misc:' . $marc->{misc_score} . '}';
309
310     my $compact = join('', $marc->{age_score}, $marc->{misc_score}, @score);
311     $marc->{score} = "$compact\t$json";
312 }
313
314 =head2 dump_fingerprints
315
316 =cut
317
318 sub dump_fingerprints {
319     my ($marc) = @_;
320
321     if ($conf->{fingerprints}{baseline}) {
322         print OF join("\t", $marc->{score}, $marc->{id}, 'baseline',
323                       $marc->{item_form}, $marc->{date1}, $marc->{record_type},
324                       $marc->{bib_lvl}, $marc->{title}), "\n";
325     }
326
327     if ($conf->{fingerprints}{oclc} and scalar @{$marc->{oclc} }) {
328         for (@{$marc->{oclc} }) {
329             print OF join("\t", $marc->{score}, $marc->{id}, "oclc",
330                           $marc->{item_form}, $marc->{date1},
331                           $marc->{record_type}, $marc->{bib_lvl},
332                           $marc->{title}, $_, "\n");
333         }
334     }
335
336     if ($conf->{fingerprints}{koha_bib_id} and exists $marc->{koha_bib_id}) {
337         print OF join("\t", $marc->{score}, $marc->{id}, "z_koha_bib_id",
338                       $marc->{item_form}, $marc->{date1},
339                       $marc->{record_type},
340                       $marc->{bib_lvl}, $marc->{title},
341                       $marc->{koha_bib_id}), "\n";
342     }
343
344     if ($conf->{fingerprints}{isbn}) {
345         if ((scalar @{ $marc->{isbns} } > 0) and $marc->{pages}) {
346             foreach my $isbn ( @{ $marc->{isbns}} ) {
347                 print OF join("\t", $marc->{score}, $marc->{id}, "isbn",
348                               $marc->{item_form}, $marc->{date1},
349                               $marc->{record_type},
350                               $marc->{bib_lvl}, $marc->{title},
351                               $isbn, $marc->{pages}), "\n";
352             }
353         }
354     }
355
356     if ($conf->{fingerprints}{edition} and $marc->{edition}) {
357         print OF join("\t", $marc->{score}, $marc->{id}, "edition",
358                       $marc->{item_form}, $marc->{date1},
359                       $marc->{record_type}, $marc->{bib_lvl},
360                       $marc->{title}, $marc->{edition}), "\n";
361     }
362
363     if ($conf->{fingerprints}{issn} and $marc->{issn}) {
364         print OF join("\t", $marc->{score}, $marc->{id}, "issn",
365                       $marc->{item_form}, $marc->{date1},
366                       $marc->{record_type}, $marc->{bib_lvl},
367                       $marc->{title}, $marc->{issn}), "\n";
368     }
369
370     if ($conf->{fingerprints}{lccn} and $marc->{lccn}) {
371         print OF join("\t", $marc->{score}, $marc->{id}, "lccn",
372                       $marc->{item_form}, $marc->{date1},
373                       $marc->{record_type}, $marc->{bib_lvl},
374                       $marc->{title}, $marc->{lccn}) ,"\n";
375     }
376
377     if ($conf->{fingerprints}{accomp} and $marc->{accomp}) {
378         print OF join("\t", $marc->{score}, $marc->{id}, "accomp",
379                       $marc->{item_form}, $marc->{date1},
380                       $marc->{record_type}, $marc->{bib_lvl},
381                       $marc->{title}, $marc->{accomp}) ,"\n";
382     }
383
384     if ($conf->{fingerprints}{authpub} and $marc->{author} and
385         $marc->{publisher} and $marc->{pubyear} and $marc->{pages}) {
386         print OF join("\t", $marc->{score}, $marc->{id}, "authpub",
387                       $marc->{item_form}, $marc->{date1},
388                       $marc->{record_type}, $marc->{bib_lvl},
389                       $marc->{title}, $marc->{author},
390                       $marc->{publisher}, $marc->{pubyear},
391                       $marc->{pages}), "\n";
392     }
393 }
394
395
396
397 =head2 dump_exception
398
399 Write line of exception report
400
401 =cut
402
403 sub dump_exception {
404     my ($marc, $msg) = @_;
405     unless (defined $marc) {
406         print XF "Undefined record at line $count; likely bad XML\n";
407         return;
408     }
409
410     print XF "Record ", $marc->{id}, " excluded: ";
411     if (defined $msg) {
412         print XF "$msg\n";
413         return
414     }
415
416     print XF "missing item_form; " unless ($marc->{item_form});
417     unless (defined $marc->{date1})
418       { print XF "missing date1; " }
419     else
420       { print XF "invalid date1: '", $marc->{date1}, "'; "
421           unless ($marc->{date1} =~ /\d{4}/); }
422     print XF "missing record_type; " unless ($marc->{record_type});
423     print XF "missing bib_lvl; " unless ($marc->{bib_lvl});
424     print XF "missing title " unless ($marc->{title});
425     print XF "\n";
426 }
427
428
429 =head2 this_record_is_excluded
430
431 Returns 1 if the record B<is> and 0 if the record B<is not> excluded,
432 according to the subfield mapping (generated via the C<--excludelist>
433 option).
434
435 =cut
436
437 sub this_record_is_excluded {
438     my ($rec, $marc) = @_;
439     return 0 unless defined $conf->{excludelist};
440
441     for my $tag (keys %{ $conf->{excludelist}->{tags} }) {
442         for my $sub (keys %{$conf->{excludelist}->{tags}{$tag}}) {
443             my $f = $conf->{excludelist}->field($tag, $sub);
444
445             # if this record doesn't have the right tag/sub, it can't be
446             return 0 unless ($rec->field($tag) and $rec->field($tag)->subfield($sub));
447             # but it does, so if there are no filters to check...
448             unless ($conf->{excludelist}->filters($f))
449               { dump_exception($marc, "exclusion $tag$sub"); return 1 }
450
451             my $sub_contents = $rec->field($tag)->subfield($sub);
452             for my $filter (@{ $conf->{excludelist}->filters($f)}) {
453                 if ($sub_contents =~ /$filter/i) {
454                     # filter matches. no fp.
455                     dump_exception($marc, "exclusion $tag$sub '$filter'");
456                     return 1;
457                 }
458                 # no match, no exclude
459                 return 0;
460             }
461         }
462     }
463 }
464
465 =head2 initialize
466
467 Performs boring script initialization. Handles argument parsing,
468 mostly.
469
470 =cut
471
472 sub initialize {
473     my ($c) = @_;
474     my @missing = ();
475
476     # set mode on existing filehandles
477     binmode(STDIN, ':utf8');
478
479     my $rc = GetOptions( $c,
480                          'exception|x=s',
481                          'output|o=s',
482                          'prefix|p=s',
483                          'marctype|m=s',
484                          'subfield|s=s',
485                          'tag|t=s',
486                          'fingerprints=s',
487                          'scores=s',
488                          'arbitrarily-lose-above=i',
489                          'arbitrarily-lose-below=i',
490                          'newwins',
491                          'excludelist=s',
492                          'quiet|q',
493                          'help|h',
494                        );
495     show_help() unless $rc;
496     show_help() if ($c->{help});
497
498     # check fingerprints list for validity
499     if ($c->{fingerprints}) {
500         my %fps = ();
501         my %valid_fps = ( oclc => 1, isbn => 1, issn => 1, lccn => 1,
502                           edition => 1, accomp => 1, authpub => 1,
503                           baseline => 1, crap => 1,
504                           koha_bib_id => 1,
505                         );
506         for (split /,/, $c->{fingerprints}) {
507             die "Invalid fingerprint '$_'\n" unless $valid_fps{$_};
508             $fps{$_} = 1;
509         }
510         $c->{fingerprints} = \%fps
511     } else {
512         $c->{fingerprints} = {oclc => 1, isbn => 1, edition => 1, issn => 1,
513                               lccn => 1, accomp => 1, authpub => 1};
514     }
515
516     # check scores list for validity
517     if ($c->{scores}) {
518         my %scores = ();
519         my %valid_scores = ( oclc => 1, dlc => 1, num_650 => 1,
520                              num_tags => 1, enc_lvl => 1,
521                            );
522         for (split /,/, $c->{scores}) {
523             die "Invalid score mode '$_'\n" unless $valid_scores{$_};
524             $scores{$_} = 1;
525         }
526         $c->{dyn_scores} = [split /,/, $c->{scores}];
527         $c->{scores} = \%scores;
528     } else {
529         $c->{scores} = {oclc => 1, dlc => 1, num_650 => 1,
530                         num_tags => 1, enc_lvl => 1};
531         $c->{dyn_scores} = [ qw/oclc dlc num_650 num_tags enc_lvl/ ];
532     }
533
534     # set defaults
535     $c->{tag} = 903 unless defined $c->{tag};
536     $c->{subfield} = 'a' unless defined $c->{subfield};
537     $c->{marctype} = 'XML' unless defined $c->{marctype};
538     if ($c->{prefix}) {
539         $c->{output} = join('.',$c->{prefix},'fp');
540         $c->{exception} = join('.',$c->{prefix},'fp','ex');
541     }
542
543     # get SFM object if excludelist was specified
544     if ($c->{excludelist}) {
545         $c->{excludelist} =
546           Equinox::Migration::SubfieldMapper->new( file => $c->{excludelist} );
547     }
548
549     my @keys = keys %{$c};
550     show_help() unless (@ARGV and @keys);
551     for my $key ('tag', 'subfield', 'output', 'exception')
552       { push @missing, $key unless $c->{$key} }
553     if (@missing) {
554         print "Required option: ", join(', ', @missing), " missing!\n";
555         show_help();
556     }
557 }
558
559
560 =head2 progress_ticker
561
562 =cut
563
564 sub progress_ticker {
565     return if $conf->{quiet};
566     printf("\r> %d recs seen; %d processed", $count, $scount);
567     printf(" (%d/s)", ($count / (time - $start + 1)))
568       if ($count % 500 == 0);
569 }
570
571 =head2 show_help
572
573 Display usage message when things go wrong
574
575 =cut
576
577 sub show_help {
578 print <<HELP;
579 Usage is: $0 [REQUIRED ARGS] [OPTIONS] <filelist>
580 Req'd Arguments
581   --output=<FILE>      -o  Output filename
582   --exceptions=<FILE>  -x  Exception report filename
583        or
584   --prefix=<PREFIX>>   -p  Shared prefix for output/exception files. Will
585                            produce PREFIX.fp and PREFIX.fp.ex
586 Options
587   --tag=N       -t  Which tag to use (default 903)
588   --subfield=X  -s  Which subfield to use (default 'a')
589   --quiet       -q  Don't write status messages to STDOUT
590
591   --fingerprints=LIST  Fingerprints to generate, comma separated
592                        Default: oclc,isbn,edition,issn,lccn,accomp,authpub
593                        Others:  baseline,koha_bib_id
594   --excludelist=FILE   Name of fingerprints exclusions file
595
596   --scores=LIST  Scores to calculate, comma separated
597                  Default: oclc,dlc,num_650,num_tags,enc_level
598   --newwins      New record IDs score higher (default is old wins)
599   --arbitrarily-lose-above
600   --arbitrarily-lose-below
601   --arbitrarily-decrease-score-by
602       Modify fingerprint scoring of records whose EG id is above or below a
603       given value, inclusive (so 5 is <= 5 or >= 5) such that they lose.
604
605   --marctype=TYPE Defaults to 'XML'
606 HELP
607 exit 1;
608 }