Ruby Script to Extract Post for RPGGeek

At one point, I had a script that converted my old Wordpress blog to the rpggeek.com syntax. Today, I spent some time writing up a script to use against my website.

I wrote the following Rake (Rake 📖) task that calls the exporter (see below).

desc "Export to RPGGeek Format"
task :export_to_rpggeek, [:path] do |task, args|
  path = args.fetch(:path, "")
  require_relative "../take_on_rules/exporter"
  $stdout.puts TakeOnRules::Exporter::ToRpgGeek.call(path: path)
end
Ruby Script to Convert Post to RPGGeek code
require 'nokogiri'
require 'open-uri'
require 'markdown-tables'
module TakeOnRules
  module Exporter
    # Responsible for converting an Hypertext Markup Language (HTML 📖) document to a RPGGeek
    # format object.
    class ToRpgGeek
      def self.call(path:)
        new(path: path).call.to_s
      end

      attr_reader :content, :path
      def initialize(path:)
        @path = path
        @content = open(path)
        @output = []
      end

      def call
        doc = Nokogiri::HTML(content)
        canonical_url = doc.css('link[rel=canonical]').attribute("href").value
        canonical_line = "[b]Originally posted at [url]#{canonical_url}[/url][/b]"
        @output << canonical_line

        body = doc.css(".content").first
        raise "Empty content for #{path.inspect}. Cannot process" unless body
        body.children.each do |node|
          line = []
          handle(node: node, line: line)
          @output << line.join(" ").strip.gsub("\n ", "\n") unless line.empty?
        end
        @output << canonical_line
        self
      end

      def to_s
        @output.join("\n\n")
      end

      private

      FONT_SIZE_MAP = {
        "h1" => 24,
        "h2" => 24,
        "h3" => 18,
        "h4" => 14,
        "footer" => 8,
        "newthought" => 14
      }.freeze
      SIMPLE_TAG_MAP = {
        "code" => "c",
        "em" => "i",
        "i" => "i",
        "s" => "-",
        "strong" => "b",
        "b" => "b"
      }
      CONTROL_CHAR_REGEXP = %r{[\t\n]+}.freeze
      ANCHOR_TAG_REGEXP =  %r{^\#}.freeze
      def handle(node:, line:)
        case node.name
        when "sup", "sub", "iframe", "script", "noscript", "aside", "details", "hr", "br", "label", "input"
          :ignore
        when "footer"
          line << "[i][size=#{FONT_SIZE_MAP.fetch("footer")}]#{node.text.gsub(CONTROL_CHAR_REGEXP,' ')}[/size][/i]"
        when "code", "em", "s", "strong", "i", "b"
          tag = SIMPLE_TAG_MAP.fetch(node.name)
          line << "[#{tag}]#{node.text.strip}[/#{tag}]"
        when "text", "cite"
          text = node.text.strip
          line << text unless text.empty?
        when "h1", "h2", "h3", "h4"
          size = FONT_SIZE_MAP.fetch(node.name)
          line << "[size=#{size}]#{node.text.strip}[/size]"
        when "ul", "ol", "dl"
          node.children.each do |child|
            handle(node: child, line: line)
          end
        when "li"
          inner_line = ["*"]
          node.children.each do |child|
            handle(node: child, line: inner_line)
          end
          inner_line << "\n"
          line << inner_line.join(" ")
        when "p"
          node.children.each do |child|
            handle(node: child, line: line)
          end
        when "blockquote"
          inner_line = ["[q]"]
          node.children.each do |child|
            handle(node: child, line: inner_line)
          end
          inner_line << "[/q]"
          line << inner_line.map(&:strip).join("\n\n")
        when "pre"
          raise "Unable to handle PRE"
        when "a"
          href = node.attribute("href").value
          if href =~ ANCHOR_TAG_REGEXP
            line << "#{node.text}"
          else
            line << "#{node.text} ([url]#{href}[/url])"
          end
        when "div", "section"
          if node.classes.include?("table-wrapper")
            node.children.each do |child|
              handle(node: child, line: line)
            end
          else
            raise "Unable to handle #{node.name.upcase}"
          end
        when "table"
          handle_table(node: node, line: line)
        when "span"
          if node.classes.include?("marginfigure")
            img = node.css("img").first
            line << "[IMG]#{img.attribute("src").value}[/IMG]" if img
          elsif node.classes.include?("sidenote") || node.classes.include?("marginnote")
            inner_line = ["[COLOR=#9900CC][i][size=9]"]
            node.children.each do |child|
              handle(node: child, line: inner_line)
            end
            inner_line << "[/size][/i][/COLOR]"
            line << inner_line.map(&:strip).join(" ")
          elsif node.classes.include?("newthought")
            line << "[i][b][size=#{FONT_SIZE_MAP.fetch("newthought")}]#{node.text}[/size][/b][/i]"
          end
        when "figure"
          raise "Unable to handle FIGURE"
        else
          raise "Unable to handle #{node.name.upcase}"
        end
      end

      # @note I don't know the results of rows with colspan or columns
      # with rowspans. This also ignores links within the table.
      def handle_table(node:, line:)
        table = Table.new(node: node)
        table.extract!
        line << table.to_line
      end

      class Table
        attr_accessor :caption, :headers, :rows, :footer
        attr_reader :node
        def initialize(node:)
          @node = node
          @rows = []
          @footer = []
        end

        def extract!
          node.children.each do |child|
            case child.name
            when "caption"
              self.caption = child.text
            when "thead"
              self.headers = child.css("tr th").map { |th| th.text.strip }
            when "tbody"
              child.css("tr").each do |tr|
                row = []
                tr.children.each do |tr_child|
                  cell_text = tr_child.text.strip
                  row << cell_text unless cell_text.empty?
                end
                self.rows << row
              end
            when "tfoot"
              child.css("tr").each do |tr|
                self.footer << tr.children.map(&:text).join(" ").gsub(CONTROL_CHAR_REGEXP, "").strip
              end
            end
          end
        end

        def to_line
          table = MarkdownTables.make_table(headers, rows, is_rows: true)
          text = "[c]\n[b]#{caption}[/b]\n"
          text += MarkdownTables.plain_text(table)
          text += %(\n[i]#{footer.join("\n")}[/i]) unless footer.empty?
          text += "\n[/c]"
        end
      end
    end
  end
end

With that task, from the command line, I can run the following: bundle exec rake export_to_rpggeek[https://takeonrules.com/2019/08/16/session-2-exploring-the-thel-sector-of-stars-without-number/]. That command outputs the following Geek Code.

Output Geek Code

[b]Originally posted at [url]https://takeonrules.com/2019/08/16/session-2-exploring-the-thel-sector-of-stars-without-number/[/url][/b]

[size=24]Table of Contents[/size]

Bear with me, because I’ve got a rather lengthy post.

* Introducing the Cast
* Session Report
* Observations
* Next Steps

[size=24]Introducing the Cast[/size]

[IMG]https://takeonrules.com/images/session-notes-2019-08-15_hu0a685245d438c06085750d8e4ca64308_1464029_360x0_resize_box_2.png[/IMG]

First a quick reminder of the player characters.

[c]
[b]Table 1: Quick Breakdown of Characters[/b]
|=============|=================|=====================|=====================================================================|=====================|
|   Player    |    Character    | Class (Background)  |                               Skills                                |        Foci         |
|=============|=================|=====================|=====================================================================|=====================|
|    Aidan    |  Aisaak Jasper  |   Warrior (Thug)    |                Shoot-1, Sneak-0, Stab-0, Survival-2                 | Assassin, Wanderer  |
|-------------|-----------------|---------------------|---------------------------------------------------------------------|---------------------|
|     Ben     |   Marce Caero   |  Psychicic (Pilot)  | Connect-0, Fix-0, Pilot-0, Trade-0, Precognition-1, Teleportation-0 |  Psychic Training   |
|-------------|-----------------|---------------------|---------------------------------------------------------------------|---------------------|
| NPC (Jacob) | Terathis Altair | Expert (Technician) |                       Fix-1, Know-1, Pilot-1                        | Starfarer, Tinkerer |
|=============|=================|=====================|=====================================================================|=====================|
[/c]

And a quick overview of the characters introduced.

[c]
[b]Table 2: NPCs introduced in the Session 2019-08-15[/b]
|====================|=========|================================================================================================================================================|
|         NPC        | Species |                                                                   Description                                                                  |
|====================|=========|================================================================================================================================================|
|       Gwandu       |   Ózu   |       Followed PCs from port. Lead them to Folded Lotus. Had climbing gear. Part of rebellion. Touchstone: Danny Devito as a day laborer.      |
|--------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------|
|         Shu        |  Human  | Dark tight curled hair. Egg shell skin. Dark eyes. Author. Drunk and drug addict. Part of rebellion. Touchstone: Starbuck at the gaming table. |
|--------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------|
|       Yauntyr      |   Ózu   |                                                   Sonorous voice. Touchstone: Frasier Crane.                                                   |
|--------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------|
|      Stephano      |  Human  |                 Pox faced, no hair color, administrator (Chaplain of the Compass Sodality) Touchstone: Steve Buscemi in Fargo.                 |
|--------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------|
| Two Unnamed Guards | Humans  |                                              Accompanied Stephano as he went from home to office.                                              |
|====================|=========|================================================================================================================================================|
[/c]

[size=24]Session Report[/size]

This week, for the second session of our Thel Sector ([url]https://takeonrules.com/series/thel-sector-campaign/[/url]) campaign, the PCs snapped into action.

[size=18]The Folded Lotus[/size]

With guidance from the portmaster, Aissack and Marce started out toward the Folded Lotus to find Shu. They noticed that an Ózu started following them. The PCs set a quick ambush, into which the Ózu stumbled.

After a quick round of questions, they learned that Gwandu (the Ózu) knew Shu and could introduce them. The PCs followed cautiously, entering the Folded Lotus.

Inside, they hear the plinking noises of Mah Jong tiles shuffling. An Ózu takes the stage with an accompanying guitarist and flutist. “I am Yauntyr, and I hope you appreciate my songs.” Yauntyr sings beautifully.

Gwandu introduces them to a drunk Shu, a woman with tight black curled hair, dark eyes, and pale skin. [COLOR=#9900CC][i][size=9] With humans living in pressurized quarters, we agreed that most would be rather pale. [/size][/i][/COLOR]

The PCs barter for information regarding their scroll, in exchange for the drugs that they smuggled through.

The Pretech artifact that they seek sits on the mantle of Chaplain Stephano, a low-level administrator of the Compass Sodality.

As Gwandu ends his set, Marce convinces him to join them as they travel back to the Ilios system. [i]Marce tells a tale of seeking a game show consultant and performer.[/i] Gwandu accepts the offer, and the PCs feel quite a lot richer knowing that if things go to plan their debt would be erased, and they’d have 8,000 credits to their name.

[size=18]Dealing with the Chaplain of the Compass Sodality[/size]

Aissock cased the Chaplain’s home, spending a day observing. [COLOR=#9900CC][i][size=9] My mind went to Blades in the Dark ([url]https://www.drivethrurpg.com/product/170689/Blades-in-the-Dark?affiliate_id=318171[/url]) . I wanted to start them in the middle of the heist, to see how deep they got before things started falling apart. [/size][/i][/COLOR] However, the guards that came to escort the Chaplain discovered Aissock.

After some conversation, with Chaplain Stephano dropping the bigoted invective “Mucus sacks of singing shit” regarding Ózu.  With a quick turn, Aissock convinced the vane Chaplain that he was observing him for promotion within the Compass Sodality. Chaplain Stephano invited Aissock into his abode for a conversation. Aissock confirmed the Pretech on the mantle.

Around this time, Terathis called up Merce: “I got to get lost for a bit. I just detected some heavy ships drawing close to the planet.” And with that, their ride bolted for the dense fog banks of Alcazar.

The PCs set a plan in motion, they would return and escort the Chaplain off-planet. Chaplain Stephano, tired of the back-water nature of Alcazar and eager to leave, he quickly accepted their lead.

Aissock came back and gathered up the Chaplain’s belongings, and they worked their way towards their spaceship. But Chaplain Stephano’s comm device rang. Aissock tensed. Took a step back. And as Stephano’s began to answer, Aissock lit him up with his laser rifle.

In quick order, they hid the body in the luggage. Then hailed Terathis, and with Yauntyr they boarded and returned to the fog.

[size=18]Going to Ground[/size]

Out in the fog, they powered down most everything. They avoided detection and waited. Days passed as they sat blind. And Terathis said, “If we could get another piece like that, I could probably modify our ship to dampen its emissions.” [COLOR=#9900CC][i][size=9] They were looking to install “ [b]Low Emissions (Fix-1):[/b] Any sensor checks to detect the ship have their difficulty increased by +2. Cost: 10% of hull, 1 component/hull class” [/size][/i][/COLOR]

Yauntyr offered that he may have friends that could point them to a place that could have more of that Pretech. A few days later, a half-dozen Ózu assembled and led them to a small poly ceramic bunker.

Poking around Marce opted to teleport into the compound. Upon arrival, he heard an “Intruder. Intruder. Psychic Intruder.” muted yet calm alarm. Marce quickly figured out the controls and let Aissock into the bunker. After a bit of exploring, they heard a sentinel waking up. [COLOR=#9900CC][i][size=9] I used the “Announce Off-Screen Badness” move outlined in Apocalypse World. [/size][/i][/COLOR] They chose to find an ambush point.

Aissock fired and hit a heavy warbot that rolled into range. Marce missed. They pressed their attack, Aissock hit again. [COLOR=#9900CC][i][size=9] He used his Warrior ability to hit. And knocked the warbot down to two HP. [/size][/i][/COLOR] The warbot fired dowing Marce and wounding Aissock. Aissock returned fire, missed, and the warbot downed Aissock. [COLOR=#9900CC][i][size=9] I gave the Warbot a ⁄ chance of misfire with each shot. Alas, it never misfired. [/size][/i][/COLOR]

They awoke, in a tank of fluid now draining. A few days had passed. In this inner room, they saw numerous Pretech components. Try as they may, something in their brains prevented their scavenging of that Pretech. Instead, the systems of the bunker offered them a bit of Pretech.

We wrapped up our session. I awarded 3 XP, enough for each character to level up.

[size=24]Observations[/size]

[i][b][size=14]I practiced a technique[/size][/b][/i] Judd Karlman talked about on Daydreaming about Dragons ([url]https://anchor.fm/daydreaming-about-dragons[/url]) , that draws firmly from Burning Wheel. Clarify task and intent before any skill test. [COLOR=#9900CC][i][size=9] If I have one take away, it is this: Listen to Daydreaming about Dragons ([url]https://anchor.fm/daydreaming-about-dragons[/url]) . Judd talks about [i]techniques[/i] he uses as a GM and engages in conversations with other GMs. He rightly identifies that GM-ing is a skill-heavy role. Eight years ago, I had a conference talk accepted “Everything I Learned about Project Management I Learned from D&D.” I changed positions and had to withdraw my presentation. The skills of a GM apply to the collaborative [i]and meeting heavy[/i] workspaces that many of us navigate. [/size][/i][/COLOR] I deliberately short-circuited conversations early to ask the players what they wanted to get out of the moment. [COLOR=#9900CC][i][size=9] I don’t usually cut conversations short, instead of letting players talk it out. However, with a skill system, I want to go to the skill checks. [/size][/i][/COLOR] Once they stated it, I moved back to the conversation and called for a roll. I set the difficulty of the roll based on intent, fictional positioning, and the novelty of it all.

I found this process helped to more quickly move further along the fiction. Instead of floundering through conversation, I interrupted the players to ask them, “What do you want out of this?” This gave them permission to think out loud.

[i][b][size=14]I asked questions[/size][/b][/i] to let the players fill in details. As the characters walked into the Folded Lotus, I asked Aidan, “How do you know the Folded Lotus sympathizes with the rebellion?”

Aidan responded, “A series of rotating pictures scroll across the screen. Occasionally those images flicker to show a crossed-out sign of the Compass Sodality.”

I replied, “That’s great, but let’s shift it a bit to reflect the appropriate technology. Small sketched portraits adorn the wall. These sepia-toned drawings honor the memory of those that died. On the wall, a poster of the Compass Sodality’s flared cross. The poster hangs upside down and is torn in half.”

[i][b][size=14]Helping clarify[/size][/b][/i] the “you don’t find any traps” paradox. At one point Aidan wanted to know if Shu sympathized with the Rebellion. I called for a [i]Wisdom/Notice[/i] roll. I chose to roll secretly, but immediately backed away from the secrecy asking, “How do you know for certain that Shu is, in fact, part of the rebellion?”

He gave a few answers, which helped build out the fiction of the game.

[i][b][size=14]A Warbot[/size][/b][/i] is a rather stiff opponent for two 1st level characters to tackle. But breaking into a Pretech compound comes with consequences. They acknowledged and knew of the extreme dangers of the impending doom. With an automatic hit from ambush and another round of fire, we all felt they could take it down. [i]Given my previous introduction, they knew that encounter balance did not exist. The players even acknowledged this understanding after the warbot incapacitated them.[/i]

[i][b][size=14]Sweet. Fast. Combat.[/size][/b][/i] Oh, how I love that decisive combat took two rounds and about five minutes.

[i][b][size=14]I planned for someone[/size][/b][/i] to follow them. I didn’t plan for the NPC’s allegiances nor loyalties. When the PCs engaged, I rolled a reaction check (see Table 3: Stars without Number reaction table ).

[c]
[b]Table 3: Stars without Number reaction table[/b]
|======|=================================================|
|  2d6 |                 Reaction Result                 |
|======|=================================================|
|   2  | Hostile, reacting as negatively as is plausible |
|------|-------------------------------------------------|
|  3-5 |        Negative, unfriendly or unhelpful        |
|------|-------------------------------------------------|
|  6-8 |     Neutral, reacting predictably or warily     |
|------|-------------------------------------------------|
| 9-11 |   Positive, potentially cooperative with PCs    |
|------|-------------------------------------------------|
|  12  |     Friendly, helpful as is plausible to be     |
|======|=================================================|
[/c]

I find that I enjoy using dice as an oracle during my game. [COLOR=#9900CC][i][size=9] Some examples include Guess Who’s Coming to Bitterweed Barrow ([url]https://takeonrules.com/2017/04/19/guess-whos-coming-to-bitterweed-barrow/#guess-whos-coming-to-bitterweed-barrow[/url]) , What NPCs learn as part of their investigation ([url]https://takeonrules.com/2017/03/25/concluding-the-doom-of-the-savage-kings/#what-npcs-learn[/url]) , and my post about What Frees Sir Uravulon Calcidius? ([url]https://takeonrules.com/2018/11/27/procedures-for-the-liberation-of-sir-uravulon-calcidius/[/url]) (from the [i]Tower of the Stargazer[/i] ) [/size][/i][/COLOR]

[i][b][size=14]Running a sci-fi game[/size][/b][/i] feels like a stretch outside my comfort zone. In particular scene description. The characters went from a TL2 underground city, to a natural atmospheric pressure created outdoor fog, and then to a Pretech bunker. I don’t have a strong visual palette to draw from. Whereas in a fantasy game, with slower movement, the look and feel from scene to scene may vary.

[i][b][size=14]I ran[/size][/b][/i] what I believe to be my first two player game. I played Tarathis as an NPC, but often kept him close to the ship. I often have 4 to 6 players in my game groups. I enjoyed the more intimate game. I found it straight forward to manage the spotlight for 2 players. I did observe that I had less downtime to think of next steps.

[size=24]Next Steps[/size]

And with that, it’s time to think through the natural consequences of their action. Will the Compass Sodality use the disappearance and murder to rain hell down on the rebels? What will the resuscitation cost the PCs?

Then grind the gears of a faction turn, because there is more going on than the Compass Sodality and the Rebellion.

[b]Originally posted at [url]https://takeonrules.com/2019/08/16/session-2-exploring-the-thel-sector-of-stars-without-number/[/url][/b]

With this code, I can more easily cross-post relevant topics to corresponding RPGGeek threads.