Skip to content

Instantly share code, notes, and snippets.

@ttscoff
Last active February 21, 2026 10:50
Show Gist options
  • Select an option

  • Save ttscoff/035c2aa08d9451b27ee1e42f4adc9cdc to your computer and use it in GitHub Desktop.

Select an option

Save ttscoff/035c2aa08d9451b27ee1e42f4adc9cdc to your computer and use it in GitHub Desktop.
NYT Connections cheater CLI
#!/usr/bin/env ruby
# frozen_string_literal: true
# Requires nokogiri and chronic (optional)
# Get hints for the New York Times Connections game from Mashable's daily article.
# Usage: connections.rb [--date STRING] [type]
# Types: hint, category, answer, words (default)
# hint: Cryptic hints about the categories
# category: The four category names
# answer: The full answers with color labels
# words: Just 1 word from each category (in case you have them all figured out but can't tell which one is purple)
#
# MIT License
# Copyright (c) 2026 Brett Terpstra. All rights reserved.
require "date"
require 'nokogiri'
require 'open-uri'
require 'optparse'
begin
require 'chronic'
rescue LoadError
# Optional; Date.parse fallback is used when unavailable.
end
TYPE_PATTERNS = {
hints: /here.s a hint/i,
categories: /today.s connections categories/i,
answers: /what is the answer/i
}.freeze
TYPE_ALIASES = {
'hint' => :hints,
'hints' => :hints,
'category' => :categories,
'categories' => :categories,
'answer' => :answers,
"answers" => :answers,
'word' => :words,
'words' => :words
}.freeze
COLOR_LABELS = [
["\e[33mYellow\e[0m", "Yellow"],
["\e[32mGreen\e[0m", "Green"],
["\e[36mBlue\e[0m", "Blue"],
["\e[35mPurple\e[0m", "Purple"]
].freeze
COLOR_PREFIX_REGEX = /^(#{COLOR_LABELS.map { |_, label| Regexp.escape(label) }.join("|")}):\s*/i.freeze
options = {
date: Date.today,
type: :words
}
def parse_date(date_string)
normalized = date_string.strip
month_day_match = normalized.match(/\A([A-Za-z]+)\s+(\d{1,2})\z/)
if month_day_match
normalized = "#{month_day_match[1]} #{month_day_match[2]} #{Date.today.year}"
end
numeric_match = normalized.match(/^(\d{1,2})([\/-])(\d{1,2})(?:\2(\d{2,4}))?$/)
if numeric_match
month = numeric_match[1].to_i
day = numeric_match[3].to_i
raw_year = numeric_match[4]
year = if raw_year.nil?
Date.today.year
elsif raw_year.length == 2
2000 + raw_year.to_i
else
raw_year.to_i
end
return Date.new(year, month, day)
end
if defined?(Chronic)
parsed = Chronic.parse(normalized)
return parsed.to_date unless parsed.nil?
end
Date.parse(normalized)
rescue ArgumentError
nil
end
OptionParser.new do |opts|
opts.banner = "Usage: connections.rb [--date STRING] [type]"
opts.separator ""
opts.separator "Types: hint, category, answer, words (default)"
opts.on("--date STRING", "Date to fetch (default: today)") do |date_string|
parsed = parse_date(date_string)
abort("Unable to parse date: #{date_string}") if parsed.nil?
options[:date] = parsed
end
end.parse!(ARGV)
requested_type = (ARGV[0] || 'words').downcase
options[:type] = TYPE_ALIASES[requested_type]
abort("Unknown type '#{requested_type}'. Use hint, category, answer, or words.") if options[:type].nil?
url = "https://mashable.com/article/nyt-connections-hint-answer-today-#{options[:date].strftime('%B-%-d-%Y')}"
doc = Nokogiri::HTML(URI.open(url))
sections = {
hints: [],
categories: [],
answers: []
}
doc.css('h2').each do |h2|
matched_type = TYPE_PATTERNS.find { |_, pattern| h2.text.match?(pattern) }&.first
next if matched_type.nil?
sibling_uls = []
h2.xpath('following-sibling::*').each do |node|
break if node.name == 'h2'
sibling_uls << node if node.name == 'ul'
sibling_uls.concat(node.css('ul').to_a) unless node.css('ul').empty?
end
ul = if matched_type == :answers
sibling_uls.find do |candidate_ul|
items = candidate_ul.css('li').map { |li| li.text.strip }.reject(&:empty?)
items.length >= COLOR_LABELS.length && items.all? { |item| item.match?(COLOR_PREFIX_REGEX) }
end
else
sibling_uls.find { |candidate_ul| candidate_ul.css('li').any? }
end
ul ||= h2.xpath('following::ul[1]').first
next if ul.nil?
sections[matched_type] = ul.css('li').map { |li| li.text.strip }.reject(&:empty?)
end
selected_items = if options[:type] == :words
sections[:answers].map do |item|
cleaned_item = item.sub(COLOR_PREFIX_REGEX, '')
answer_list = cleaned_item.include?(':') ? cleaned_item.split(':', 2).last : cleaned_item
first_answer = answer_list.split(',').first.to_s.strip
first_answer.sub(/[,:;]+$/, '')
end
else
sections[options[:type]]
end
abort("No items found for #{options[:type]} at #{url}") if selected_items.empty?
COLOR_LABELS.each_with_index do |(colored_label, plain_label), index|
item = selected_items[index]
next if item.nil? || item.empty?
normalized_item = item.sub(/^#{Regexp.escape(plain_label)}:\s*/i, "")
puts "#{colored_label}: #{normalized_item}"
end
if selected_items.length > COLOR_LABELS.length
selected_items[COLOR_LABELS.length..].each do |item|
puts item
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment