Last active
February 21, 2026 10:50
-
-
Save ttscoff/035c2aa08d9451b27ee1e42f4adc9cdc to your computer and use it in GitHub Desktop.
NYT Connections cheater CLI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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