Note

This is the column as I originally wrote it. But the magazine editors didn't like the reference to Hooters or the "Am I Hot or Not?" site, so I had to quickly rewrite it as the deadline loomed.

Oddly, they did a piece on "Am I Hot or Not?" just two months later. Coincidence? I wonder.

Copyright Notice

This text is copyright by CMP Media, LLC, and is used with their permission. Further distribution or use is not permitted.

This text has appeared in an edited form in WebTechniques magazine. However, the version you are reading here is as the author originally submitted the article for publication, not after their editors applied their creativity.

Please read all the information in the table of contents before using this article.
Download this listing!

Web Techniques Column 59 (Mar 2001)

[suggested title: Simplified Voting]

I heard about the ``Am I Hot or Not?'' site a little while back, and it was all the rage (and might still be, when you read this), being able to put a random picture up and coming back later to see how people had voted. And with all that presidential election news last year, the word ``voting'' just kept coming up, and I thought it was time to talk about how tough (or easy) it is to take a poll yourself.

So, I quickly hacked up a voting scheme using DBI, including a mechanism to reject duplicate votes (well, at least someone immediately hitting reload to vote the same), and made it possible to run it under mod_perl's Apache::Registry. But what to vote on?

Well, I'm a big fan of the Hooters Restaurant chain, and a buddy of mine (Bill Harp) is thinking about opening one in my hometown area (which at the moment is Hooter-less). So when he accompanies me on business trips occasionally, we visit the local Hooters for some of their nice hospitality, good chicken wings, and, let's admit it, the beautiful waitresses.

After many trips to their website (http://www.originalhooters.com/) to get the locations nearest my next port-of-call, I noticed a link to a random picture of the day of one of their calendar images. The links turned out to be randomly generated, but into a predictable series of pathnames, and it wasn't long before I was able to determine all 500 (!) picture paths (using a few probing Perl programs).

So, it occurred to me that I could use this database as a source reference for my voting program. Mind you, this whole thing breaks if the Hooters webmaster moves the files around, but as long as it's working, we're voting now. Also, I consulted with a lawyer, and as long as I'm not misrepresenting the pictures as my own (and a direct competitor), current caselaw says I'm permitted to ``deep link'' directly to their site as I've done. Cool.

And of course, since this was all originally inspired by ``Am I Hot or Not?'', I needed a similar name, so I announce the code of ``Am I Hoot-er-Not?'', presented in [listing one, below].

Lines 1 and 2 start nearly all the Perl programs I present here. In Apache::Registry, the first line is actually ignored, but I put it here in case I move this to a normal CGI directory (and it would still work).

Line 4 pulls in the CGI module, importing all symbols.

Line 6 defines a top-level URL for all the URLs to which we deep link over at the Hooters website. This isn't really a configuration parameter, but just a program-wide constant. If the website moves things around, the whole program is likely to be useless.

Lines 8 to 20 capture the SQL I need to have MySQL create the table. It's a simple record, with the disk/image number (together representing a unique image on the Hooters site), the timestamp of when then vote was made (both for statistics and to reject most ballot-stuffing attempts), the IP address of the vote (again, for statistics and to reject duplicate votes), and the vote itself (something between 5 and 11).

Line 22 handles the CGI header, title, and visible heading.

Lines 24 to 26 declare three mostly-global variables, described as needed below.

Lines 28 to 88 handle the incoming vote mechanics. I put these in a separate block because I was going to create a bunch of variables and didn't want them to collide with variables later, and they logically go together.

Lines 29 to 34 validate the incoming vote. If we don't have a proper disk number, image number, or rating, we don't have a vote, and we act like it's a new call to the page (generating a random image selection). Note the use of \A and \z as beginning-of-string and end-of-string anchors on the regular expressions.

If we have a valid vote, we need to connect to the vote database. Lines 36 to 38 do that, setting the RaiseError attribute which will cause us to die if anything goes wrong. This is easier than checking the result of every database access, but requires an eval block around anything that might fail.

Lines 40 to 45 note whether this particular IP address has voted for this image within the past 60 minutes. If so, we ignore this vote. And this is the heart of our simple-minded ballot-stuffing avoider.

Only one vote for hour is permitted from a given IP address. Why IP address? Well, it's a distinguishing characteristic, although flawed for people voting behind firewalls or from dialups. Why only an hour? Same reasons. I don't want to lock out two different people behind the same firewall voting for the same picture, so the hour-long timeout lets the next person come along and vote as well. Note that no indication is given that a vote was not accepted, especially the timeframe, so a ballotstuffer would have to work very hard to figure out that a new vote could be triggered once an hour.

Once the vote is legal, we store it into the database in lines 47 to 50. Note that we let MySQL fill in the timestamp, to keep it consistent with the query from the previous section. If the system clock for the MySQL machine was off by five minutes, ``one hour ago'' is still correct because it's relative to the times already gathered with the incorrect clock.

Finally, regardless of whether or not we have a new vote, we calculate the average of the old votes, using the code in lines 53 to 58. Note in all three of these database accesses, we've used placeholders so that I don't have to worry about quoting conventions in the data.

Lines 60 to 63 pretty-up the ``number of votes'' data, trying to avoid warnings from undef as much as possible.

Lines 65 to 73 create the ``previous vote'' display to the left of the main display. Initially, the $previous_vote value is empty, but if we have a previous vote, we set it to a table. The table is there for layout (sorry, purist friends). Notice that we build an inline image link pointing at the Hooters website, in thumbnail size (it's convenient that every image already comes scaled in five sizes). I copied the layout and wording here directly from the ``Am I Hot Or Not'' site.

Lines 75 to 85 compute the ``next'' image to vote on. I originally had just chosen a random image each time, but I was getting anomolies by calling rand so many times without reseeding the data, and couldn't quickly get enough bits from the random generator to do the job. So after each image, I pick the next one. Image numbers go from 001 to 100, and disk numbers go from 1 to 5, wrapping around on both.

Line 87 is needed to prevent warnings if we're running as a CGI script, but is ignored under mod_perl if we've pulled in Apache::DBI (which attempts to cache database connections for speed).

Lines 90 through 98 pick the new image to vote on, if needed. When we compile this code, we'll spin the dial once (with srand). Then if we aren't coming back from a previous vote, we'll get a random starting point for a disk and image. The three calls to param preset the ``sticky fields'' for form generation.

And then comes the messy ``tables within tables'' layout code for the main output, starting in line 100. This started simple, and I kept patching it. But it works. Line 103 dumps the previous vote as a table cell on the left, if any.

Lines 104 to 121 dump the ``right'' cell, which is where all the output happens, as a a two-by-two table. The upper left cell is the voting area, again with wording taken from ``Am I Hot or Not?''. A radio group for the rating, and values from 5 to 11 (arbitrary scale that I felt was appropriate) get their buttons. The rows parameter in line 111 makes yet-another nested layout table to ensure the buttons are horizontally placed. Two hidden fields in lines 112 and 113 ensure that we'll know who the vote is for. The upper right cell is the submit button.

The lower left cell starting in line 115 is the image itself, once again an IMG link pointing directly at the Hooters website. This time, we pick size Small, which is a bit larger than the thumbnail used earlier, and again, just luckily about the same size as the voting images on ``Am I Hot or Not?''.

Finally, the lower right cell is a list of links of all five sizes for this particular image, again, all pointing directly toward the Hooters website for fulfillment. Notice that at no time am I bringing an image from their site and resending it along to the voter's browser: this is all handled by the deep links directly to their site.

Lines 124 to 130 are an attempt to ensure that the Hooters Owl doesn't get his little feathers ruffled too much, nor the allusion to ``Am I Hot or Not?'' gets lost. One word: ``parody''. And that's it.

Now, in order for this to be useful, people have to know about it, so I inserted this program into my Apache::Registry-enabled area on my website at http://www.stonehenge.com/perl/amihooternot, and then announced it from my home page and on a few select IRC channels. Within a few days, I had accumulated enough votes to start getting some interesting data.

Once data has accumulated, it's a matter of extracting what I want, such as the top ten average votes for which at least five votes were cast:

  SELECT disk, image, avg(vote) AS average, count(*) AS votes
  FROM votes
  GROUP BY disk, image
  HAVING votes > 5
  ORDER BY average DESC
  LIMIT 10

Or the bottom 10, which is the same without the DESC above. A small matter of programming to turn the results into a nice table for display, but at this point, I think you can see where to take it from here. Until next time, vote early, vote often, and enjoy!

Listings

        =1=     #!/usr/bin/perl
        =2=     use strict;
        =3=     
        =4=     use CGI qw(:all);
        =5=     
        =6=     my $BASE = "http://www.originalhooters.com/Calendar";;
        =7=     
        =8=     =for MySQL
        =9=     
        =10=    DROP TABLE votes;
        =11=    CREATE TABLE votes (
        =12=      disk TINYINT DEFAULT 1 NOT NULL,
        =13=      image TINYINT DEFAULT 1 NOT NULL,
        =14=      when TIMESTAMP DEFAULT 0 NOT NULL,
        =15=      host CHAR(16) DEFAULT '127.0.0.1' NOT NULL,
        =16=      vote TINYINT,
        =17=      INDEX (disk, image, when, host)
        =18=    )
        =19=    
        =20=    =cut
        =21=    
        =22=    print header, start_html("Am I hoot-er-not?"), h1("Am I hoot-er-not?");
        =23=    
        =24=    my $previous_vote = "";
        =25=    my $disk;
        =26=    my $image;
        =27=    
        =28=    {
        =29=      defined (my $votedisk = param("disk")) or last;
        =30=      defined (my $voteimage = param("image")) or last;
        =31=      defined (my $rating = param("rating")) or last;
        =32=      $votedisk =~ /\A[1-5]\z/ or last;
        =33=      $voteimage =~ /\A\d{3}\z/ and $voteimage >= 1 and $voteimage <= 100 or last;
        =34=      $rating =~ /\A(5|6|7|8|9|10|11|abstain)\z/ or last;
        =35=    
        =36=      require DBI;
        =37=      my $dbh = DBI->connect("dbi:mysql:merlyn_amihooternot",
        =38=                             "username", "password", { RaiseError => 1 });
        =39=    
        =40=      ## verify no recent previous vote
        =41=      if ($rating ne "abstain" and not
        =42=          $dbh->selectrow_array('SELECT count(*) FROM votes ' .
        =43=                                'WHERE disk = ? AND image = ? AND host = ? ' .
        =44=                                'AND when > DATE_SUB(NOW(), INTERVAL 1 HOUR)',
        =45=                                undef, $votedisk, $voteimage, $ENV{REMOTE_ADDR})) {
        =46=    
        =47=        ## store vote
        =48=        $dbh->do("INSERT INTO votes (disk, image, when, host, vote) ".
        =49=                 "VALUES (?, ?, now(), ?, ?)",
        =50=                 undef, $votedisk, $voteimage, $ENV{REMOTE_ADDR}, $rating);
        =51=      }
        =52=    
        =53=      ## get average vote, count
        =54=      my ($average, $count) =
        =55=        $dbh->selectrow_array('SELECT avg(vote), count(*) FROM votes ' .
        =56=                              'WHERE disk = ? AND image = ? ' .
        =57=                              'GROUP BY disk, image',
        =58=                              undef, $votedisk, $voteimage);
        =59=      $average = defined $average ? (sprintf "%.1f", $average) : "?";
        =60=      $count =
        =61=        (not defined $count or $count == 0) ? "no votes" :
        =62=          ($count == 1) ? "1 vote" :
        =63=            "$count votes";
        =64=        
        =65=      $previous_vote =
        =66=        table({-border => 1},
        =67=              Tr(td({align => 'center'},
        =68=                    "What others thought", br,
        =69=                    $average, br,
        =70=                    "based on $count", br,
        =71=                    "You rated her: $rating", br,
        =72=                    img({src => "$BASE/Disk$votedisk/Thumb/$voteimage.jpg"}),
        =73=                   )));
        =74=    
        =75=      if ($voteimage > 99) {
        =76=        $image = "001";
        =77=        if ($votedisk > 5) {
        =78=          $disk = 1;
        =79=        } else {
        =80=          $disk = $votedisk + 1;
        =81=        }
        =82=      } else {
        =83=        $image = sprintf "%03d", $voteimage + 1;
        =84=        $disk = $votedisk;
        =85=      }
        =86=    
        =87=      $dbh->disconnect;             # not needed in mod_perl
        =88=    }
        =89=    
        =90=    unless ($disk) {
        =91=      BEGIN { srand; }
        =92=      $disk = 1 + int rand 5;
        =93=      $image = sprintf "%03d", 1 + int rand 100;
        =94=    }
        =95=    
        =96=    param("disk", $disk);
        =97=    param("image", $image);
        =98=    param("rating", "abstain");
        =99=    
        =100=   print
        =101=     start_form,
        =102=     table({-border => 0, -colspacing => 0, -colpadding => 2},
        =103=           Tr(td({-valign => 'top'}, $previous_vote),
        =104=              td(table(Tr(td({-align => 'right'},
        =105=                             p("Tell me, am I a",
        =106=                               a({-href => "/cgi/go/http://www.originalhooters.com/";},
        =107=                                 "Hooters Girl"),
        =108=                               "or not?"),
        =109=                             radio_group(-name => "rating",
        =110=                                         -values => [qw(5 6 7 8 9 10 11 abstain)],
        =111=                                         -rows => 1),
        =112=                             hidden("disk"),
        =113=                             hidden("image")),
        =114=                          td(submit("vote"))),
        =115=                       Tr(td(
        =116=                             ## "disk $disk image $image<br>", # debug
        =117=                             img({src => "$BASE/Disk$disk/Small/$image.jpg"})),
        =118=                          td(table(Tr(td("see more:")),
        =119=                                   map {
        =120=                                     Tr(td(a({-href => "/cgi/go/$BASE/Disk$disk/$_/$image.jpg"}, $_)))
        =121=                                   } qw(Thumb Small Medium Large Exlarge)))))))),
        =122=     end_form;
        =123=   
        =124=   print
        =125=     p("Please note that images may be copyrighted by their",
        =126=       "respective owners, and that this voting system",
        =127=       "is not affiliated in any way with the Hooters Restaurant chain",
        =128=       "or related to (no matter how inspired by) the",
        =129=       a({-href => "/cgi/go/http://www.amihotornot.com/";},
        =130=         "Am I Hot Or Not?"), "site."),
        =131=   
        =132=   print end_html;

Randal L. Schwartz is a renowned expert on the Perl programming language (the lifeblood of the Internet), having contributed to a dozen top-selling books on the subject, and over 200 magazine articles. Schwartz runs a Perl training and consulting company (Stonehenge Consulting Services, Inc of Portland, Oregon), and is a highly sought-after speaker for his masterful stage combination of technical skill, comedic timing, and crowd rapport. And he's a pretty good Karaoke singer, winning contests regularly.

Schwartz can be reached for comment at merlyn@stonehenge.com or +1 503 777-0095, and welcomes questions on Perl and other related topics.