The Big Book of Small EK9 Programs

Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.

Project 11 — Clickbait Headline Generator

Clickbait Headline Generator writes fake tabloid-style headlines by filling in random blanks — a state, a noun, a pronoun, a number — drawn from word lists and dropped into one of eight fill-in-the-blank templates, as many times as the player asks for.

Adapted from project 11 of Al Sweigart's The Big Book of Small Python Projects (No Starch Press, 2021), under CC BY-NC-SA 4.0.

A sample run

Run with BIGBOOK_SEED=42 ek9 clickbait.ek9 < input.txt, answering the only prompt with a count of 6 headlines:

6
By Al Sweigart al@inventwithpython.com

Our website needs to trick people into looking at ads!
Enter the number of clickbait headlines to generate:
This Georgia Serial Killer Didn't Think Robots Would Take His Job. He Was Wrong.
Big Companies Hate Her! See How This Ohio Parent Invented a Cheaper Cat
You Won't Believe What This Texas Dog Found in Her Workplace
What Clowns Don't Want You To Know About Paleo Diets
This Florida Robot Didn't Think Robots Would Take Her Job. She Was Wrong.
Big Companies Hate Them! See How This Georgia Serial Killer Invented a Cheaper Avocado

Post these to our Facesbook soon or you're fired!

The program

#!ek9
defines module bigbook.clickbait

  defines constant

    FewestGiftIdeas <- 7
    MostGiftIdeas <- 15
    FewestReasons <- 3
    MostReasons <- 19

  defines type

    HeadlineType
      Millennials
      WithoutThis
      BigCompanies
      YouWontBelieve
      DontWant
      GiftIdeas
      ReasonsWhy
      JobAutomated

    List of String
    List of HeadlineType
    List of Pronouns

  defines record

    //The original keeps three parallel pronoun lists and picks the same index from two of them.
    //One record per person keeps "Her", "She" and "Was" together, so they cannot disagree.
    Pronouns
      objective as String: String()
      possessive as String: String()
      personal as String: String()
      pastOfBe as String: String()

      Pronouns() as pure
        ->
          objective as String
          possessive as String
          personal as String
          pastOfBe as String

        this.objective :=? objective
        this.possessive :=? possessive
        this.personal :=? personal
        this.pastOfBe :=? pastOfBe

      default operator ?

    //One headline the game has drawn: its kind and the blanks that kind fills. A kind uses
    //only some of the blanks; the others stay unset.
    Headline
      kind as HeadlineType: HeadlineType()
      person as Pronouns: Pronouns()
      state as String: String()
      noun as String: String()
      otherNoun as String: String()
      place as String: String()
      timing as String: String()
      number as Integer: Integer()
      otherNumber as Integer: Integer()

      default operator ?

  defines class

    //THE input idiom of this book, copied unchanged from lib/LineInput.ek9 (the reasons are
    //there): read with next(), and every input loop also stops when ended() is true.
    LineInput
      stdin as Stdin: Stdin()
      inputEnded as Boolean: false

      next()
        <- rtn as String: stdin.next()
        if not rtn?
          inputEnded: true

      ended() as pure
        <- rtn as Boolean: Boolean(inputEnded)

      override operator ? as pure
        <- rtn as Boolean: true

    //THE INPUT AND OUTPUT: everything the player sees or types goes through here, worded by Say.
    Console
      stdout as Stdout: Stdout()
      input as LineInput: LineInput()
      say as Say: Say("en")

      Console()
        -> words as Say
        say: words

      showIntroduction()
        stdout.println(say.introduction())

      //Asks until the reply is a number; unset only if the input ends first.
      nextCount()
        <- rtn as Integer: Integer()
        while not rtn? and not input.ended()
          stdout.println(say.askCount())
          rtn: countFrom(input.next())
          if not rtn? and not input.ended()
            stdout.println(say.notANumber())

      showHeadline()
        -> headline as Headline
        stdout.println(headlineText(headline))

      showSignOff()
        ->
          website as String
          timing as String

        stdout.println("")
        stdout.println(say.postThese(website, timing.lowerCase()))

      //Words a drawn headline with the template for its kind.
      private headlineText()
        -> headline as Headline
        <- rtn as String: String()
        switch headline.kind
          case HeadlineType.Millennials
            rtn: say.areMillennialsKilling(headline.noun)
          case HeadlineType.WithoutThis
            rtn: say.withoutThis(headline.noun, headline.otherNoun, headline.timing)
          case HeadlineType.BigCompanies
            rtn: say.bigCompaniesHate(
              pronoun: headline.person.objective,
              state: headline.state,
              noun1: headline.noun,
              noun2: headline.otherNoun
            )
          case HeadlineType.YouWontBelieve
            rtn: say.youWontBelieve(
              state: headline.state,
              noun: headline.noun,
              pronoun: headline.person.possessive,
              place: headline.place
            )
          case HeadlineType.DontWant
            rtn: say.dontWantYouToKnow(headline.noun, headline.otherNoun)
          case HeadlineType.GiftIdeas
            rtn: say.giftIdeas(headline.number, headline.noun, headline.state)
          case HeadlineType.ReasonsWhy
            rtn: say.reasonsWhy(headline.number, headline.noun, headline.otherNumber)
          case HeadlineType.JobAutomated
            rtn: say.jobAutomated(
              state: headline.state,
              noun: headline.noun,
              pronoun1: headline.person.possessive,
              pronoun2: headline.person.personal,
              verb: headline.person.pastOfBe
            )
          default
            rtn: String()

      override operator ? as pure
        <- rtn as Boolean: true

  defines function

    //THE GAME: the word lists and the draw, with no input or output. The word lists are data
    //the game draws from, as the book's lists are.

    people() as pure
      <- rtn as List of Pronouns: [
        Pronouns(objective: "Her", possessive: "Her", personal: "She", pastOfBe: "Was"),
        Pronouns(objective: "Him", possessive: "His", personal: "He", pastOfBe: "Was"),
        Pronouns(objective: "Them", possessive: "Their", personal: "They", pastOfBe: "Were")
      ]

    states() as pure
      <- rtn as List of String: [
        "California", "Texas", "Florida", "New York", "Pennsylvania",
        "Illinois", "Ohio", "Georgia", "North Carolina", "Michigan"
      ]

    nouns() as pure
      <- rtn as List of String: [
        "Athlete", "Clown", "Shovel", "Paleo Diet", "Doctor", "Parent", "Cat", "Dog", "Chicken",
        "Robot", "Video Game", "Avocado", "Plastic Straw", "Serial Killer", "Telephone Psychic"
      ]

    places() as pure
      <- rtn as List of String: [
        "House", "Attic", "Bank Deposit Box", "School", "Basement", "Workplace", "Donut Shop",
        "Apocalypse Bunker"
      ]

    //`when` is an EK9 keyword, so the original's WHEN list is named timings.
    timings() as pure
      <- rtn as List of String: ["Soon", "This Year", "Later Today", "RIGHT NOW", "Next Week"]

    websites() as pure
      <- rtn as List of String: ["wobsite", "blag", "Facebuuk", "Googles", "Facesbook", "Tweedie", "Pastagram"]

    //Integer(text) is unset when the text is not a number, so the parse is the validation.
    countFrom() as pure
      -> reply as String
      <- rtn as Integer: Integer(reply.trim())

    pick()
      ->
        items as List of String
        rng as Random
      <- rtn as String: items.getOrDefault(rng.next(0, length items - 1), String())

    pickPerson()
      -> rng as Random
      <- rtn as Pronouns: Pronouns()
      everyone <- people()
      rtn: everyone.getOrDefault(rng.next(0, length everyone - 1), Pronouns())

    pickPlural()
      ->
        items as List of String
        rng as Random
      <- rtn as String: pick(items, rng) + "s"

    //The headline types come from the enumeration itself, so adding a type adds it to the draw.
    pickHeadlineType()
      -> rng as Random
      <- rtn as HeadlineType: HeadlineType()
      kinds <- cat HeadlineType | collect as List of HeadlineType
      rtn: kinds.getOrDefault(rng.next(0, length kinds - 1), HeadlineType())

    //The kind is drawn first, then the blanks that kind fills, in the order its template
    //reads them.
    drawnHeadline()
      -> rng as Random
      <- rtn as Headline: Headline()
      rtn.kind: pickHeadlineType(rng)
      switch rtn.kind
        case HeadlineType.Millennials
          rtn.noun: pick(nouns(), rng)
        case HeadlineType.WithoutThis
          rtn.noun: pick(nouns(), rng)
          rtn.otherNoun: pickPlural(nouns(), rng)
          rtn.timing: pick(timings(), rng)
        case HeadlineType.BigCompanies
          rtn.person: pickPerson(rng)
          rtn.state: pick(states(), rng)
          rtn.noun: pick(nouns(), rng)
          rtn.otherNoun: pick(nouns(), rng)
        case HeadlineType.YouWontBelieve
          rtn.state: pick(states(), rng)
          rtn.noun: pick(nouns(), rng)
          rtn.person: pickPerson(rng)
          rtn.place: pick(places(), rng)
        case HeadlineType.DontWant
          rtn.noun: pickPlural(nouns(), rng)
          rtn.otherNoun: pickPlural(nouns(), rng)
        case HeadlineType.GiftIdeas
          rtn.number: rng.next(FewestGiftIdeas, MostGiftIdeas)
          rtn.noun: pick(nouns(), rng)
          rtn.state: pick(states(), rng)
        case HeadlineType.ReasonsWhy
          rtn.number: rng.next(FewestReasons, MostReasons)
          rtn.noun: pickPlural(nouns(), rng)
          rtn.otherNumber: rng.next(1, rtn.number)
        case HeadlineType.JobAutomated
          rtn.person: pickPerson(rng)
          rtn.state: pick(states(), rng)
          rtn.noun: pick(nouns(), rng)
        default
          rtn: Headline()

  defines function

    //THE source of randomness, copied unchanged from lib/RandomSource.ek9: SystemRandom(), or
    //SeededRandom(seed) when BIGBOOK_SEED holds a number (run-and-diff.sh sets it from seed.txt).
    randomSource()
      <- rtn as Random: SeededRandom(Integer(EnvVars().get("BIGBOOK_SEED"))) ?: SystemRandom()

    //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it
    //is set (fr, zh, ...), otherwise en.
    language()
      <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en"

  //THE WORDS: every word the player sees - the eight headline templates among them - apart
  //from the word lists the game draws the blanks from.
  defines text for "en"

    Say

      introduction()
        `By Al Sweigart al@inventwithpython.com

Our website needs to trick people into looking at ads!`

      askCount()
        "Enter the number of clickbait headlines to generate:"

      notANumber()
        "Please enter a number."

      areMillennialsKilling()
        -> noun as String
        `Are Millennials Killing the ${noun} Industry?`

      withoutThis()
        ->
          noun as String
          pluralNoun as String
          timing as String
        `Without This ${noun}, ${pluralNoun} Could Kill You ${timing}`

      bigCompaniesHate()
        ->
          pronoun as String
          state as String
          noun1 as String
          noun2 as String
        `Big Companies Hate ${pronoun}! See How This ${state} ${noun1} Invented a Cheaper ${noun2}`

      youWontBelieve()
        ->
          state as String
          noun as String
          pronoun as String
          place as String
        `You Won't Believe What This ${state} ${noun} Found in ${pronoun} ${place}`

      dontWantYouToKnow()
        ->
          pluralNoun1 as String
          pluralNoun2 as String
        `What ${pluralNoun1} Don't Want You To Know About ${pluralNoun2}`

      giftIdeas()
        ->
          number as Integer
          noun as String
          state as String
        `${number} Gift Ideas to Give Your ${noun} From ${state}`

      reasonsWhy()
        ->
          number1 as Integer
          pluralNoun as String
          number2 as Integer
        `${number1} Reasons Why ${pluralNoun} Are More Interesting Than You Think (Number ${number2} Will Surprise You!)`

      jobAutomated()
        ->
          state as String
          noun as String
          pronoun1 as String
          pronoun2 as String
          verb as String
        `This ${state} ${noun} Didn't Think Robots Would Take ${pronoun1} Job. ${pronoun2} ${verb} Wrong.`

      postThese()
        ->
          website as String
          timing as String
        `Post these to our ${website} ${timing} or you're fired!`

  defines program

    //Clickbait Headline Generator (Big Book project 11). Makes up as many clickbait headlines as
    //the player asks for. The game decides; the Console asks and tells, in the player's words.
    //The golden replays a seeded game (seed.txt).
    Clickbait()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))
      rng <- randomSource()

      console.showIntroduction()
      if count <- console.nextCount()
        made <- 0
        while made < count
          console.showHeadline(drawnHeadline(rng))
          made += 1
        website <- pick(websites(), rng)
        timing <- pick(timings(), rng)
        console.showSignOff(website, timing)

How the port works

The original builds each headline in the same lines that choose its random blanks and print it. The port keeps three things apart: the game draws a headline, the Console asks for the count and shows each headline, and Say holds every word.

The game draws. The word lists — states, nouns, places, timings, websites, people — are data the game draws the blanks from, as the book's lists are. drawnHeadline(rng) draws a kind, then the blanks that kind fills, in the order its template reads them, and returns a Headline record: the kind and its blanks, the value and never the words for it. A kind uses only some of the record's blanks; the others stay unset, which does no harm, because each template is handed only its own. HeadlineType is an enumeration of the eight kinds, replacing a numbered 1-to-8 choice, and the list it is drawn from is derived from the type itself, cat HeadlineType | collect as List of HeadlineType, so a ninth headline type added to the enumeration joins the random draw automatically — there is no second list that could drift out of step with it.

The original's three parallel word lists — one each for a pronoun's objective, possessive and personal form, plus a separate check for whether the past tense of "to be" should read "Was" or "Were" — become one Pronouns record per person, built with a pure, assign-if-unset (:=?) constructor. "Her", "She" and "Was" (or "Their", "They" and "Were") travel together as one object picked by one random index, so there is no way for a slipped index to pair "Their" with "He".

The Console asks and tells. nextCount() uses the same unset-guard idiom as the other ports in this book, while not rtn? and not input.ended(), reading an unset Integer for "not a number yet" rather than any other sentinel. headlineText words a drawn headline with the template for its kind: a switch over HeadlineType that must name every one of the eight values and still carry a default — two separate rules the compiler enforces, so a ninth case genuinely cannot be forgotten. EK9's switch has no fallthrough to guard against in the first place, so each case simply stands alone, much like a chain of Python elifs.

Say holds the words. The eight headline templates are methods on a text construct, each an interpolated template such as `Are Millennials Killing the ${noun} Industry?`, beside the introduction, the prompts and the sign-off. This is EK9's dedicated construct for locale-specific, parameterised strings: a second language would be a second defines text for "..." block alongside this one, and the compiler checks that every locale of the same text declares the same methods — there is no risk of an English-only template quietly going untranslated. Several templates take four or more arguments, and from four arguments up EK9 requires every call to name them — say.jobAutomated(state: ..., noun: ..., pronoun1: ..., pronoun2: ..., verb: ...) rather than five bare positions — which is a compiler-enforced readability rule, not a style choice.

The program is the flow: the introduction, the count, one drawn headline after another, then the sign-off with a drawn website and timing. It chooses the words with Say(language()) ?: Say("en"), so a language with no words of its own falls back to English. when turns out to be a reserved EK9 keyword — it cannot even be used as a variable name — so the original's WHEN word list is named timings here instead.