Annotation of comics/fetch.pl.new, revision 1.30
1.1 nick 1: #!/usr/bin/perl -w
2:
1.15 nick 3: ###############################################################################
1.16 nick 4: # $Log: fetch.pl.new,v $
1.30 ! nick 5: # Revision 1.29 2020/06/10 21:32:52 nick
! 6: # Centered page
! 7: #
1.29 nick 8: # Revision 1.28 2020/06/10 21:14:31 nick
9: # Updated for w3 validation.
10: #
1.28 nick 11: # Revision 1.27 2019/04/15 12:50:23 nick
12: # The script was unable to handle html '&' and convert it, so I added that. I probably should see if there's a library or something that handles all those automagically but I just tossed a regex in there for now that does the trick.
13: #
1.27 nick 14: # Revision 1.26 2018/04/22 14:03:54 nick
15: # Changed the default for Sunday comics that was causing issues with some comics.
16: #
1.26 nick 17: # Revision 1.25 2018/02/12 13:30:58 nick
18: # Added an easier to compare date string to determine if the status json file was updated today and report if it wasn't.
19: #
1.25 nick 20: # Revision 1.24 2018/02/06 14:31:06 nick
21: # A status report is now generated in JSON that can easily be scanned so that
22: # I can be alerted when there are failures that I miss if I don't read the
23: # comics that day.
24: #
1.24 nick 25: # Revision 1.23 2018/01/26 13:05:27 nick
26: # Added a new config option to remove all newline from the resulting index.html
27: # file. This allows for easier parsing for certain comics. I then updated
28: # the URLs to search for and enabled the newline removal for a handful
29: # of uComics.
30: #
31: # I believe I've also properly fixed the Comic Config version displayed on
32: # the webpage itself.
33: #
1.23 nick 34: # Revision 1.22 2017/12/05 13:37:40 nick
35: # Added the CVS config version to the outpuit.
36: #
1.22 nick 37: # Revision 1.21 2015/10/26 14:25:40 nick
38: # Fixed a bug that was improperly including the day of week string preventing the weekend comics from fetching proproperly.
39: #
1.21 nick 40: # Revision 1.20 2015/10/22 12:58:44 nick
41: # Added the ability for Sunday only comics. Stonesoup is no longer weekdays, this has been added to Sunday only. I also added Foxtrot Classics for weekdays and Foxtrot for Sundays.
42: #
1.20 nick 43: # Revision 1.19 2015/07/13 12:56:58 nick
44: # Added Sally Forth and Pearls Before Swine. Adding Sally Forth required a change in the 'wget' command for fetching the index file to include 'user-agent' and 'referer'.
45: #
1.19 nick 46: # Revision 1.18 2015/05/07 12:31:43 nick
47: # Added favicon
48: #
1.18 nick 49: # Revision 1.17 2015/02/19 14:56:10 nick
50: # Fixed a problem that forced everything to JPG. This would kill GIF animations, but would not display the gifs either because 'convert' appends an index number to the end of the file name for each from of the GIF animation. I fixed this to maintain GIF compatibilty as well as rewritting how the script fetches the size of the file. Additionally, I updated the configuration for Questionable Content to search for GIF or JPG, which is what triggered this entire update.
51: #
1.17 nick 52: # Revision 1.16 2015/02/05 18:05:58 nick
53: # Changed the background and added a fancy title.
54: #
1.16 nick 55: # Revision 1.15 2015/01/19 13:46:19 nick
56: # *** empty log message ***
57: #
1.15 nick 58: ###############################################################################
59:
1.1 nick 60: use strict;
61: use File::Path;
62: use Data::Dumper;
1.8 nick 63: use Pod::Usage;
64: use Getopt::Long;
1.24 nick 65: use JSON::Create 'create_json';
1.21 nick 66: use Date::Calc qw/Date_to_Text_Long Today Day_of_Week Day_of_Week_to_Text/;
1.30 ! nick 67: use Data::Dumper;
1.16 nick 68:
1.1 nick 69: ##
70: ## Some default values
71: ##
1.30 ! nick 72: my $ver = '$Id: fetch.pl.new,v 1.29 2020/06/10 21:32:52 nick Exp $';
1.1 nick 73: my $comicFile = "comics.conf";
1.22 nick 74: my $comicConfigVer = "Unknown";
1.24 nick 75: my $reportFile = "/home/httpd/html/daily/comics/status_report.json";
1.1 nick 76: my %comics = &readComicConfig ( $comicFile );
1.8 nick 77: my %opts = &fetchOptions( );
78: my $days_ago = $opts{'days'} || 0;
1.1 nick 79: my %dates = &fetchDates();
80: my $baseDir = $comics{'configs'}{'base_directory'} || ".";
81: my $imageDir = $baseDir . "/" . ( $comics{'configs'}{'image_directory'} || "images" ) .
82: "/$dates{'mon2'}$dates{'year2'}";
83: my $indexDir = $baseDir . "/" . ( $comics{'configs'}{'index_directory'} || "indexes" );
1.2 nick 84: my $USER_AGENT = "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.2.18) Gecko/20110628 Ubuntu/10.10 (maverick) Firefox/3.6.18";
1.8 nick 85: my @days = qw/ Sunday Monday Tuesday Wednesday Thursday Friday Saturday /;
1.1 nick 86:
87: my $DATE=`date`; chomp $DATE;
88: print STDOUT "Starting comic fetch at $DATE\n";
89:
90: ##
91: ## Main program starts here
92: ##
93: &checkDir ( [ $imageDir, $indexDir ] );
94:
1.5 nick 95: &writeTitle ( \%dates );
1.1 nick 96:
97: foreach my $comic ( sort keys %comics ) {
1.20 nick 98:
99: ## Skip if this is Sunday and the comic is weekdays only
1.1 nick 100: next if ( $comic =~ m/config/ );
1.21 nick 101: if (($dates{'wday'} eq "Sunday") &&
1.26 nick 102: ($comics{$comic}{'not_sunday'} == 1)) {
1.20 nick 103: print "Skipping '$comic'; Weekdays only.\n";
104: next;
105: }
106:
107: ## Skip if Sunday only comic and it's not Sunday.
1.21 nick 108: if (($dates{'wday'} ne "Sunday") &&
1.20 nick 109: ($comics{$comic}{'sunday_only'} == 1)) {
1.21 nick 110: print "Skipping '$comic' ($comics{$comic}{'sunday_only'}); Sunday only.\n";
1.20 nick 111: next
112: }
1.26 nick 113:
1.1 nick 114: $comics{$comic}{'error'} = &downloadComic ( \%comics, $comic, \%dates );
115: &writeComic ( \%comics, $comic, \%dates );
116:
1.17 nick 117: my $file = "$imageDir/$comic-$dates{'day2'}.$comics{$comic}{'ext'}";
118: my $size = 0;
119:
120: my $cmd = "/usr/bin/identify -verbose $file|";
121: open(IMG, $cmd) || die ("Can't open: $!\n");
122: while(<IMG>) {
123: if ($_ =~ m/^\s+geometry:\s+(\d+)x\d+.*/i) {
124: $size = $1 if ( $size == 0);
125: }
126: }
127: close(IMG);
1.4 nick 128:
1.19 nick 129:
1.30 ! nick 130: system( "/usr/bin/convert -resize 800 $file $file" )
! 131: if ( $size > 800 )
1.4 nick 132: }
133:
1.1 nick 134: ## &writeMainIndex ( \%dates );
135:
136: &writeFooter( \%dates );
137:
1.24 nick 138: print STDOUT "Status written to $reportFile.\n"
139: if (&writeStatusReportJSON(\%comics, $reportFile));
140:
1.1 nick 141: $DATE=`date`; chomp( $DATE );
142: print STDOUT "Completed comic fetch at $DATE\n";
143:
144: ## End
145:
146: #######################################################################
147: ## Function : downloadComic
148: ##
149: ## Description :
150: ## This function determines the download method being used to
151: ## retrieve the comic and calls the apprioriate function.
152: ##
153: ## If the mode is invalid an error will be returned.
154: ##
155: #######################################################################
156: sub downloadComic ($$) {
157: my ( $comics, $comic, $date ) = @_;
158:
159: SWITCH: {
160: if ( $comics->{$comic}{'mode'} eq 1 ) {
161: return indexDownload ( \%comics, $comic, $date );
162: last SWITCH;
163: }
164: if ( $comics->{$comic}{'mode'} eq 2 ) {
165: return directDownload ( \%comics, $comic, $date );
166: last SWITCH;
167: }
168: }
169:
170: return "ERROR: Unknown download method specified for $comics->{$comic}{'fullName'}.";
171: }
172:
173: #######################################################################
174: #######################################################################
175: sub readComicConfig ($$) {
176: my ( $comicFile ) = @_;
177: my %comicConfig = ( );
178: my %config = ( );
179:
1.14 nick 180: my ($year, $mon, $day) =( localtime(time))[5,4,3];
181: $year += 1900;
182: $mon = sprintf("%02d", ($mon + 1));
183: $day = sprintf("%02d", $day);
184:
1.1 nick 185: open FILEN, "<$comicFile";
186: while (<FILEN>) {
1.24 nick 187: #if ($_ =~ m/^#.* \$[Ii][Dd]: fetch.pl.new,v 1.23 2018/01/26 13:05:27 nick Exp $/) {
188: if ($_ =~ m/^#.* \$[Ii][dD]: .*,v\ (.*)\ \d{4}\/.*\$$/) {
1.22 nick 189: $comicConfigVer = $1;
190: }
1.1 nick 191: if ( ( $_ !~ m/^#/ ) && ( $_ =~ m/,.*,/) ){
1.14 nick 192: $_ =~ s/__YEAR__/$year/g;
193: $_ =~ s/__MON__/$mon/g;
194: $_ =~ s/__DAY__/$day/g;
195:
1.1 nick 196: my @res = split /,/, $_;
197: $comicConfig{$res[0]}{'url'} = $res[1];
198: $comicConfig{$res[0]}{'search'} = $res[2];
199: $comicConfig{$res[0]}{'mode'} = $res[3];
200: $comicConfig{$res[0]}{'fullName'} = $res[4];
201: $comicConfig{$res[0]}{'ext'} = $res[5];
1.26 nick 202: $comicConfig{$res[0]}{'not_sunday'} = sprintf("%d", $res[6] || 0);
1.21 nick 203: $comicConfig{$res[0]}{'sunday_only'} = sprintf("%d", $res[7] || 0);
1.23 nick 204: $comicConfig{$res[0]}{'remove_newlines'} = sprintf("%d", $res[8] || 0);
1.1 nick 205: $comicConfig{$res[0]}{'error'} = 0;
206: }
207: elsif ( $_ =~ m/(.*)\s+=\s+(.*)/ ) {
208: $comicConfig{'configs'}{$1} = $2;
209: }
210: }
211: close (FILEN);
212:
213: return %comicConfig;
214: }
215:
216: #######################################################################
217: #######################################################################
1.24 nick 218: sub writeStatusReportJSON ($$) {
219: my ( $comicsRef, $filename ) = @_;
220: my %comics = %$comicsRef;
1.25 nick 221: my $shortDate = sprintf("%d%02d%02d", (localtime)[5] + 1900,
222: (localtime)[4] + 1,
223: (localtime)[3]);
1.27 nick 224: my %json = ('date' => $shortDate, 'comics' => ());
1.24 nick 225: my $totalErrors = 0;
226:
227: foreach my $comic (sort keys %comics) {
228: next unless $comics{$comic}{'fullName'};
229: if ($comics{$comic}{'error'}) {
230: my %error = ('comicName' => "$comics{$comic}{'fullName'}",
231: 'error' => "$comics{$comic}{'error'}",
232: 'status' => "Error");
1.27 nick 233: push @{$json{'comics'}}, \%error;
1.24 nick 234: $totalErrors += 1;
235: } else {
236: my %status = ('comicName' => "$comics{$comic}{'fullName'}",
237: 'error' => 0,
238: 'status' => "Successfull");
1.27 nick 239: push @{$json{'comics'}}, \%status;
1.24 nick 240: }
241: }
242: $json{'totalErrors'} = $totalErrors;
243:
244: open SR, ">$filename" or die ("ERROR: Failed to create status report: $!\n");
245: print SR create_json (\%json);
246: close(SR);
247: }
248:
249: #######################################################################
250: #######################################################################
1.1 nick 251: sub writeComic ($$) {
252: my ( $comics, $comic, $date ) = @_;
1.11 nick 253: my $sd = substr( join( '', $days[$date->{'dow'}] ), 0, 3 );
1.12 nick 254: my $indexFile = $indexDir . "/index-" . $date->{'year2'} .
255: $date->{'mon2'} . $date->{'day2'} . "-" .
256: $sd . ".html";
1.28 nick 257: $comics->{$comic}{'fullName'} =~ s/&/&/g;
1.1 nick 258: my $content = <<EOF;
259:
260: <!-- ********* Begin $comic ($comics->{$comic}{'fullName'}) ******* -->
261: <tr>
262: <td align="left">
263: <font color="blue"><b>$comics->{$comic}{'fullName'}</b></font>
264: <font size="-2">
265: <a href="$comics->{$comic}{'url'}">
266: $comics->{$comic}{'url'}
267: </a>
268: </font><br/>
1.17 nick 269: <img src="../images/$date->{'mon2'}$date->{'year2'}/$comic-$date->{'day2'}.$comics->{$comic}{'ext'}" alt="$comic-$date->{'day2'}" />
1.1 nick 270: <br/><br/>
271: </td></tr>
272: <!-- ********* Finish $comic ($comics->{$comic}{'fullName'}) ******* -->
273:
274: EOF
275: open INDEX, ">>$indexFile";
276:
277: print INDEX $content if ( ! $comics->{$comic}{'error'} );
278:
279: print INDEX <<EOF
280: <font color="blue"><b>$comics->{$comic}{'fullName'}</b></font>
281: <font size="-2"><
282: <a href="$comics->{$comic}{'url'}">
283: $comics->{$comic}{'url'}
284: </a>
285: </font><br/>
286: <font color="red"><b>$comic : $comics->{$comic}{'error'}</b></font><br/>
287: </td>
288: </tr>
289: EOF
290: if ( $comics->{$comic}{'error'} );
291:
292: close (INDEX);
293:
294: return 0;
295: }
296:
297:
298: #######################################################################
299: #######################################################################
300: sub writeMainIndex ($$) {
301: my ( $date ) = @_;
302:
303: }
304:
305:
306: #######################################################################
307: #######################################################################
308: sub writeFooter {
309: my ( $date ) = @_;
1.11 nick 310: my $sd = substr( join( '', $days[$date->{'dow'}] ), 0, 3 );
1.12 nick 311: my $indexFile = $indexDir . "/index-" . $date->{'year2'} .
312: $date->{'mon2'} . $date->{'day2'} . "-" .
313: $sd . ".html";
1.1 nick 314: my $sysDate = `date`;
315:
316: open INDEX, ">>$indexFile";
317: print INDEX <<EOF;
318: </table>
1.3 nick 319: <center>
1.28 nick 320: Generated on: <font size="2" color="green">$sysDate</font><br/>
321: Version: <font size="2" color="green">$ver</font><br />
322: Config Version: <font size="2" color="green">$comicConfigVer</font><br />
323: CVS: <a href="http://demandred.dyndns.org:3000/cgi-bin/cvsweb/comics/">http://demandred.dyndns.org/cgi-bin/cvsweb/comics/</a>
324: <br />
1.1 nick 325: <a href="http://validator.w3.org/check?uri=referer"><img
326: src="http://www.w3.org/Icons/valid-xhtml10-blue" alt="Valid XHTML 1.0 Transitional" height="31" width="88" border="0" /></a>
327: </center>
328:
329: </body>
330: </html>
331: EOF
332: close( INDEX );
333: }
334:
335: #######################################################################
336: #######################################################################
337: sub checkDir ($$) {
338: my @dir = @_;
339:
340: foreach ( @dir ) {
341: if ( ! -d $_ ) { mkpath( $_ ); }
342: }
343: }
344:
345: #######################################################################
346: #######################################################################
347: sub writeTitle ($$) {
348: my ( $date ) = @_;
1.11 nick 349: my $sd = substr( join( '', $days[$date->{'dow'}] ), 0, 3 );
1.12 nick 350: my $indexFile = $indexDir . "/index-" . $date->{'year2'} .
351: $date->{'mon2'} . $date->{'day2'} . "-" .
352: $sd . ".html";
1.8 nick 353: my $today = $days[$date->{'dow'}] . " " . $date->{'mon'} . "/" . $date->{'day'} . "/" . $date->{'year'};
1.16 nick 354: my $today_long = Date_to_Text_Long(Today());
1.1 nick 355:
356: open INDEX, ">$indexFile";
357: print INDEX <<EOF;
358: <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
359:
360: <html xmlns="http://www.w3.org/1999/xhtml">
361: <head>
362: <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
1.28 nick 363: <link href="/daily/comics/comics.css" type="text/css" rel="stylesheet" media="screen" />
364: <link rel="shortcut icon" href="./favicon.ico" />
1.1 nick 365: <title>Daily Comics for $today</title>
366: </head>
367: <body bgcolor="#FFFFFF">
1.29 nick 368: <table align="center" cellpadding="0" cellspacing="0" border="0">
1.28 nick 369: <tr><td align="left"><img src="images/daily_comics_heading01.png" alt="Comic Page Heading" /></td></tr>
1.16 nick 370: <tr><td align="left">$today_long</td></tr>
371: <tr><td> </td></tr>
1.1 nick 372: EOF
373: close (INDEX);
374: }
375:
376: #######################################################################
377: #######################################################################
378: sub directDownload ($$) {
379: my ( $comics, $comic, $date ) = @_;
380: my $file = &parseComic ( $comics, $comic, $date );
381:
382: ##
383: ## Save the file to the appropriate directory
384: ##
385: my $cDir = $date->{'mon2'} . $date->{'year2'};
386: my $cDate = $date->{'day2'};
387:
1.30 ! nick 388: my $cmd = "wget --no-check-certificate -q $file --referer='" . $comics->{$comic}{'url'} ."' --user-agent=\"$USER_AGENT\" -O - | /usr/bin/convert - jpeg:images/$cDir/$comic-$cDate.jpg";
1.14 nick 389:
1.1 nick 390: return system($cmd);
391: }
392:
393: #######################################################################
394: #######################################################################
395: sub indexDownload ($$) {
396: my ( $comics, $comic, $date ) = @_;
397: my ( @lines, $comicLine, $mainURL );
398: my $comicIndex = "indexes/index.$comic";
399:
1.30 ! nick 400: print("Getching Index $comicIndex.\n");
! 401: print("comic url: $comics->{$comic}{'url'}\n");
! 402:
! 403: print Dumper($comics->{$comic});
! 404:
! 405: my $wget_cmd = "wget --referer='$comics->{$comic}{'url'}' " .
! 406: "--no-check-certificate --user-agent=\"$USER_AGENT\" " .
1.19 nick 407: "$comics->{$comic}{'url'} -O $comicIndex";
1.30 ! nick 408: print ("Using wget command:\n$wget_cmd\n");
! 409:
! 410: my $status = system($wget_cmd);
! 411:
! 412: print ("Return status: $status\n");
1.1 nick 413:
414: if ( ! open FILEN, "<$comicIndex" ) {
415: return "ERROR: Can't open index file for " . $comics->{$comic}{'fullName'} .
416: " (" . $comics->{$comic}{'url'} . ")";
417: }
1.23 nick 418: while (<FILEN>) {
419: my $line = $_;
1.27 nick 420: $line =~ s/\R|\ \ +|\t//g if ( $comics->{$comic}{'remove_newlines'} );
1.23 nick 421: push @lines, $line;
422: }
1.1 nick 423: close (FILEN);
424:
1.27 nick 425:
1.1 nick 426: unlink ("$comicIndex");
427:
428: $mainURL = $comics->{$comic}{'url'};
429: ## I need to figure out how to merge these two in to one regex.
430: $mainURL =~ s/(http:\/\/.*)(?:\/.*\/){1,}.*/$1/;
431: $mainURL =~ s/([a-z])\/.*/$1/i;
432:
433: ##
434: ## Find the comic strip URL based on the specified regex in the search
435: ##
1.27 nick 436:
1.1 nick 437: foreach my $line (@lines) {
1.17 nick 438: if ( $line =~ m/$comics->{$comic}{'search'}/i ) {
1.1 nick 439: $comicLine = $1; chomp $comicLine;
440: }
1.17 nick 441: }
1.1 nick 442:
443: ##
444: ## Save the file to the appropriate directory
445: ##
446: my $cDir = $date->{'mon2'} . $date->{'year2'};
447: my $cDate = $date->{'day2'};
448:
449: if ( $comicLine ) {
450: if ( $comicLine =~ m/(gif|jpg|png)/i ) { $comics->{$comic}{'ext'} = $1; }
451: my $comicURL = ( $comicLine =~ m/http/ ) ? $comicLine : $mainURL . $comicLine;
1.27 nick 452: # Strip &
453: $comicURL =~ s/\&\;/&/g;
1.30 ! nick 454: my $cmd = "wget --no-check-certificate --user-agent=\"$USER_AGENT\" --referer='" . $comics->{$comic}{'url'} . "' -q '$comicURL' -O images/$cDir/$comic-$cDate.$comics->{$comic}{'ext'}";
1.1 nick 455: system( $cmd );
456: return 0;
457: }
458:
459: unlink "index.html";
460:
461: return "ERROR: Could not download comic $comics->{$comic}{'fullName'}";
462: }
463:
464: #######################################################################
465: #######################################################################
466: sub parseComic ($$) {
467: my ( $comics, $comic, $date ) = @_;
468: my $string = $comics->{$comic}{'search'};
469:
470: $string =~ s/__year__/$date->{'year'}/g;
471: $string =~ s/__year2__/$date->{'year2'}/g;
472: $string =~ s/__mon__/$date->{'mon'}/g;
473: $string =~ s/__mon2__/$date->{'mon2'}/g;
474: $string =~ s/__day__/$date->{'day'}/g;
475: $string =~ s/__day2__/$date->{'day2'}/g;
476: $string =~ s/__ext__/$comics->{$comic}{'ext'}/g;
477: chomp $string;
478:
479: return $string;
480: }
481:
482: #######################################################################
483: #######################################################################
484: sub fetchDates () {
485: my %dates = ();
486:
1.8 nick 487: ($dates{'day'}, $dates{'mon'}, $dates{'year'}, $dates{'dow'}) = (localtime(time - (86400 * $days_ago )))[3,4,5,6];
1.1 nick 488:
489: $dates{'year'} += 1900;
490: $dates{'year2'} = substr $dates{'year'}, 2, 2;
491: $dates{'day2'} = ( $dates{'day'} < 10 ) ? "0" . $dates{'day'} : $dates{'day'};
492: $dates{'mon'}++;
493: $dates{'mon2'} = ( $dates{'mon'} < 10 ) ? "0".$dates{'mon'} : $dates{'mon'};
1.21 nick 494: my @days = qw/ Sunday Monday Tuesday Wednesday Thursday Friday Saturday /;
495: $dates{'wday'} = $days[$dates{'dow'}];
1.1 nick 496:
497: return %dates;
498: }
1.8 nick 499:
500: ###############################################################################
501: ##
502: ## &fetchOptions( );
503: ##
504: ## Grab our command line arguments and toss them in to a hash
505: ##
506: ###############################################################################
507: sub fetchOptions {
508: my %opts;
509:
510: &GetOptions(
511: "days:i" => \$opts{'days'},
512: "help|?" => \$opts{'help'},
513: "man" => \$opts{'man'},
514: ) || &pod2usage( );
515: &pod2usage( ) if defined $opts{'help'};
516: &pod2usage( { -verbose => 2, -input => \*DATA } ) if defined $opts{'man'};
517:
518: return %opts;
519: }
520:
521: __END__
522:
523: =head1 NAME
524:
525: fetch.pl - Fetches comics and places them all locally in a single html file.
526:
527: =head1 SYNOPSIS
528:
529: fetch.pl [options]
530:
531: Options:
532: --days,d Fetch comics from X days ago
533: --help,? Display the basic help menu
534: --man,m Display the detailed man page
535:
536: =head1 DESCRIPTION
537:
538: =head1 HISTORY
539:
540: =head1 AUTHOR
541:
542: Nicholas DeClario <nick@declario.com>
543:
544: =head1 BUGS
545:
546: This is a work in progress. Please report all bugs to the author.
547:
548: =head1 SEE ALSO
549:
550: =head1 COPYRIGHT
551:
552: =cut
553:
554:
FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>