https://davekiss.com/blog/how-i-won-2750-using-javascript-ai-and-a-can-of-wd-40 * About * Blog * Speaking * Write better How I won $2,750 using JavaScript, AI, and a can of WD-40 I've won many marketing video contest promotions over the past decade using my proven techniques and tactics. This particular haul, however, was the first where I can give at least partial credit to the application of code and AI tools. Here's how I used ChatGPT and a little bit of JavaScript to figure out I could win this video contest... and then proceed to win it. My second kid was born in June. I'm loving it, of course, but there's not much I can do with bleary eyes and a six-week old strapped to my chest. However... Walking around the house tending to my Honeydew list with a spray can of WD-40 falls squarely into the achievable bucket. As such, the WD-40 Repair Challenge video contest caught my attention. image I don't get very excited about online contests. I get excited when online contest rules are structured in a way that makes them particularly winnable. First, let's walk through the different qualifications and clauses in this contest's ruleset that made it a great candidate for participation. Three favorable rules 1. A gotcha in the judging criteria I only enter contests where entrant submissions are judged. Random selection sweepstakes or awards for most votes (popularity contests) are never considered. This weighted breakdown contained a line that ultimately ruled out over 80% of the submissions. image By awarding 1/41/41/4 of the judging points to the submission simply being video content, it's clear that the promoter was prioritizing video entries. However, the contest also accepted photo submissions in addition to video submissions. Since photo submissions immediately receive a 0/ 250/250/25 under this weighted clause, I decided to rule out all entrant photo submissions as non-competitive since their maximum submission score could only be 75/10075/10075/100. For this contest, I also liked that the overall quality of the content didn't really matter all that much. Normally, a heavier weight associated with this kind of clause would work in my favor as I prioritize high quality work, but remember... I have a baby strapped to my chest. You just ain't gonna get my best output at the moment. image [?][?] The more complicated the weighted breakdown becomes, the more interested I am in participating in the contest. Nobody is assessing these weighted breakdowns as much as I am. I got nothin' but time, baybee. 2. A wide array of winnable prizes image Obviously, we're aiming for first place here, but there are 16 cash prizes and 13 physical prizes up for grabs -- a total of 29 available prizes. That's unheard of. Still, we need to be sure that our chances at winning the prize(s) outweigh the effort required to submit a competitive entry. We're not trying to take on a side-hustle moonlighting contract here. We want to put in as few hours as possible for the best possible outcome. 3. The possibility to win more than one prize If you have the capacity to create a bunch of entries (nnn), this should clearly be great news for you. image image Assessing the competition All told, there were 538 entries competing for prizes in the contest. n/(538+n)n/(538+n)n/(538+n) doesn't sound like great odds, does it? Let's dig deeper to see why winning isn't as improbable as it sounds on the surface. To do this, we'll review the existing submissions. The contest website was built using Laravel Livewire. Livewire doesn't return raw JSON data about the contest submissions from its API -- instead, it serves HTML over the wire. That meant that I couldn't just hit a /submissions endpoint to get information about each submission -- I'd have to scrape it. I set up a Playwright script to page through the entry gallery and collect the data I'd need to assess the existing competitive entries. Copy const { chromium } = require("playwright"); const startScraping = async () => { const browser = await chromium.launch({ headless: false }); const context = await browser.newContext(); const page = await context.newPage(); await page.goto("https://repair.wd40.com/gallery"); // click button with "Reject All" as the button label await page.click('button:text("Reject All")'); await getAllEntries(page); await browser.close(); } startScraping(); Pretty basic to begin. We're navigating to the submission gallery, declining the cookies banner, and then initiating the submission aggregation process. image Sorting through submission types As part of understanding my odds, I needed to determine the type of submission for each entry. Remember, I can rule out any photo entries as competitors due to the significant point reduction they'll face. The only way to figure out the type of submission for each entry was to detect which SVG icon was used to represent the submission. Upon further review, it turns out that there was actually three different submission types: video, photo, and step. Step submissions allowed for providing additional written detail in your entry. Since clearly identifying the steps taken to repair your project accounted for 10/10010/10010/100 points, I assumed that a step entry type was the most likely to win a prize. At this point, I decided all of my submissions would now be step entries. image Here's how the getAllEntries scraping pagination function and submission type attribution was implemented: Copy // Use the source code for the submission icons to identify submission types const IMG_ICON = ``.trim(); const VIDEO_ICON = ``.trim(); const STEP_ICON = `` let totalEntries = 0; // Initialize a counter for total entries let imageEntries = 0; // Initialize a counter for image entries let stepEntries = 0; // Initialize a counter for step entries let videoEntries = 0; // Initialize a counter for video entries const getAllEntries = async (page) => { const entries = await page.$$("ul.grid li.contents"); totalEntries += entries.length; // Increment total entries by the number of entries found on this page for (const entry of entries) { const entryHtml = await entry.innerHTML(); const a = await entry.$("a"); const href = await a.getAttribute("href"); if (entryHtml.includes(IMG_ICON)) { // This is a non-competitive photo entry imageEntries++; // Increment image entries counter without logging } else { if (entryHtml.includes(VIDEO_ICON)) { console.log('Competitive video entry found'); videoEntries++; // Increment video entries counter } else if (entryHtml.includes(STEP_ICON)) { console.log('Competitive step entry found'); stepEntries++; // Increment step entries counter } else { console.log('Unknown entry type'); } console.log(href); // Log href to non-image entries for inspection } } const nextButton = await page.$('a[rel="next"]'); if (nextButton) { await nextButton.click(); await page.waitForResponse(response => response.status() === 200); // wait a few seconds to allow for loading and prevent rate limiting await page.waitForTimeout(3200); await getAllEntries(page); // recursive call } else { console.log('No next button found, reached the end'); // All done, we can now assess the submissions and make our conclusions here console.log(`Total entries processed: ${totalEntries}`); console.log(`Image entries: ${imageEntries}`); // Log the total number of image entries console.log(`Video entries: ${videoEntries}`); // Log the total number of video entries console.log(`Step entries: ${stepEntries}`); // Log the total number of step entries } } In this function, we're recursively performing a series of tasks: 1. Get the number of descendants in the ul.grid li.contents element (each descendant is a contest entry) 2. For each descendant; a. Get the href so we can log it out to the console if we care to review the entry b. Detect which SVG icon is used to represent the entry i. If it's an image entry, ignore it, since we're fairly confident we can beat it c. Increment the corresponding entry type counter 3. Look for the Next page button a[rel="next"] a. If it exists, click it, wait for the next page of entries to load, and repeat the process from Step 1. b. If it doesn't exist, we've reached the end of all submissions and can log out our results I ran the script with node index.js. The following results were printed in the console: Copy No next button found, reached the end Total entries processed: 538 Image entries: 439 Video entries: 17 Step entries: 67 Of the 538538538 contest entries, 439439439 of them were understood to be unqualified image entries. 439/538=0.8159851301439/538 = 0.8159851301439/538=0.8159851301 That's 81% of all entries that we can immediately disregard. The Pareto principle. Of course! Let's remove those 439 entries from consideration now: 538-439=99538-439 = 99538-439=99 Odds of randomly winning a single prize: n/(99+n)n/(99+n)n/(99+n) Remember, this judged contest has 29 prizes. Quality still matters We've already weeded out the vast majority of entries as being non-competitive, but there's still more we can disregard. This is a judged competition. Low quality entries, poor storytelling, hokey acting, and bad editing are just a few of the pillars where coming up short can lead you to the losers bucket. With this being the case, I'm going to role play a bit and pretend I'm a judge. If your entry simply doesn't meet what I'd consider to be a quality piece of content, I'm going to loosely call it a loser. I visited a chunk of these entries and watched them. If the video/ step submission simply wasn't very good, I'd copy the submission slug and store it as a non-competitor in an array. Then, while paging through the gallery, if a losing slug was detected, I'd delineate it as a nonCompetitor. Copy // Check if the href contains any known non-competitor const KNOWN_NON_COMPETITORS = ['imagine', 'some', 'not', 'so', 'great', 'entry', 'slugs', 'in', 'this', 'list', 'that', 'have', 'been', 'redacted', 'to', 'be', 'kind'] let nonCompetitorEntries = 0; // Initialize a counter for non-competitor entries const isNonCompetitor = KNOWN_NON_COMPETITORS.some(nonCompetitor => href.includes(nonCompetitor)); if (isNonCompetitor) { nonCompetitorEntries++; // Increment non-competitor entries counter continue; // Skip logging and counting this entry } >[?] The playback experience for the videos in the submission gallery wasn't all that great. Slow loading of unoptimized MP4s, no thumbnail previews, unremarkable