Compare commits
2 Commits
6bcf6ead0c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| babca5df51 | |||
| d9295fa366 |
@@ -0,0 +1,123 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from wordle_solver.nyt_check import fetch_todays_answer, maybe_check_nyt_word
|
||||||
|
from wordle_solver.solver import Solver
|
||||||
|
|
||||||
|
|
||||||
|
def _write_lists(data_dir, answers, guesses):
|
||||||
|
(data_dir / "answers.txt").write_text("\n".join(answers) + "\n")
|
||||||
|
(data_dir / "guesses.txt").write_text("\n".join(guesses) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(body: dict):
|
||||||
|
class _Resp:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return json.dumps(body).encode()
|
||||||
|
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchTodaysAnswer:
|
||||||
|
def test_returns_solution_on_success(self):
|
||||||
|
with patch("wordle_solver.nyt_check.urllib.request.urlopen",
|
||||||
|
return_value=_mock_response({"solution": "CRANE"})):
|
||||||
|
assert fetch_todays_answer() == "crane"
|
||||||
|
|
||||||
|
def test_returns_none_on_network_error(self):
|
||||||
|
with patch("wordle_solver.nyt_check.urllib.request.urlopen", side_effect=OSError("boom")):
|
||||||
|
assert fetch_todays_answer() is None
|
||||||
|
|
||||||
|
def test_returns_none_on_malformed_body(self):
|
||||||
|
with patch("wordle_solver.nyt_check.urllib.request.urlopen",
|
||||||
|
return_value=_mock_response({"solution": "notfive"})):
|
||||||
|
assert fetch_todays_answer() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMaybeCheckNytWord:
|
||||||
|
def test_skip_flag_short_circuits(self, tmp_path):
|
||||||
|
solver = Solver(["crane"], ["trace"])
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer") as fetch:
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=tmp_path / "state.json",
|
||||||
|
skip=True)
|
||||||
|
assert result is None
|
||||||
|
fetch.assert_not_called()
|
||||||
|
|
||||||
|
def test_cache_hit_skips_fetch(self, tmp_path):
|
||||||
|
_write_lists(tmp_path, ["crane"], ["trace"])
|
||||||
|
state_path = tmp_path / "state.json"
|
||||||
|
state_path.write_text(json.dumps({"last_checked": date.today().isoformat()}))
|
||||||
|
solver = Solver(["crane"], ["trace"])
|
||||||
|
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer") as fetch:
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
fetch.assert_not_called()
|
||||||
|
|
||||||
|
def test_adds_missing_word_and_updates_state(self, tmp_path):
|
||||||
|
_write_lists(tmp_path, ["crane"], ["zesty"])
|
||||||
|
state_path = tmp_path / "state.json"
|
||||||
|
solver = Solver(["crane"], ["zesty"])
|
||||||
|
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value="zesty"):
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path)
|
||||||
|
|
||||||
|
assert result == "zesty"
|
||||||
|
assert "zesty" in solver.answers
|
||||||
|
assert "zesty" not in solver.extra_guesses
|
||||||
|
assert "zesty" in solver.candidates
|
||||||
|
assert (tmp_path / "answers.txt").read_text().splitlines() == ["crane", "zesty"]
|
||||||
|
assert json.loads(state_path.read_text())["last_checked"]
|
||||||
|
|
||||||
|
def test_already_known_word_is_noop_but_updates_state(self, tmp_path):
|
||||||
|
_write_lists(tmp_path, ["crane"], [])
|
||||||
|
state_path = tmp_path / "state.json"
|
||||||
|
solver = Solver(["crane"], [])
|
||||||
|
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value="crane"):
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert json.loads(state_path.read_text())["last_checked"]
|
||||||
|
|
||||||
|
def test_network_failure_does_not_update_state(self, tmp_path):
|
||||||
|
_write_lists(tmp_path, ["crane"], [])
|
||||||
|
state_path = tmp_path / "state.json"
|
||||||
|
solver = Solver(["crane"], [])
|
||||||
|
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value=None):
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert not state_path.exists()
|
||||||
|
|
||||||
|
def test_force_bypasses_cache(self, tmp_path):
|
||||||
|
_write_lists(tmp_path, ["crane"], ["zesty"])
|
||||||
|
state_path = tmp_path / "state.json"
|
||||||
|
state_path.write_text(json.dumps({"last_checked": date.today().isoformat()}))
|
||||||
|
solver = Solver(["crane"], ["zesty"])
|
||||||
|
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value="zesty") as fetch:
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path, force=True)
|
||||||
|
|
||||||
|
fetch.assert_called_once()
|
||||||
|
assert result == "zesty"
|
||||||
|
|
||||||
|
def test_corrupt_state_file_treated_as_empty(self, tmp_path):
|
||||||
|
_write_lists(tmp_path, ["crane"], [])
|
||||||
|
state_path = tmp_path / "state.json"
|
||||||
|
state_path.write_text("not json")
|
||||||
|
solver = Solver(["crane"], [])
|
||||||
|
|
||||||
|
with patch("wordle_solver.nyt_check.fetch_todays_answer", return_value=None):
|
||||||
|
result = maybe_check_nyt_word(solver, data_dir=tmp_path, state_path=state_path)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
@@ -5,6 +5,7 @@ import random
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from .game import Clue, score_guess, str_to_pattern
|
from .game import Clue, score_guess, str_to_pattern
|
||||||
|
from .nyt_check import maybe_check_nyt_word
|
||||||
from .solver import MAX_GUESSES, WORD_LEN, Solver, rank_guesses
|
from .solver import MAX_GUESSES, WORD_LEN, Solver, rank_guesses
|
||||||
from .wager import Wager
|
from .wager import Wager
|
||||||
from .words import add_answer_word, load_answers, load_extra_guesses
|
from .words import add_answer_word, load_answers, load_extra_guesses
|
||||||
@@ -155,6 +156,11 @@ def run(args=None):
|
|||||||
help="weight on per-position letter frequency (default: 1.0)")
|
help="weight on per-position letter frequency (default: 1.0)")
|
||||||
parser.add_argument("--presence-weight", type=float, default=1.0,
|
parser.add_argument("--presence-weight", type=float, default=1.0,
|
||||||
help="weight on overall letter presence frequency (default: 1.0)")
|
help="weight on overall letter presence frequency (default: 1.0)")
|
||||||
|
nyt_group = parser.add_mutually_exclusive_group()
|
||||||
|
nyt_group.add_argument("--no-nyt-check", action="store_true",
|
||||||
|
help="skip the automatic check of today's NYT Wordle answer")
|
||||||
|
nyt_group.add_argument("--force-nyt-check", action="store_true",
|
||||||
|
help="check today's NYT Wordle answer even if already checked today")
|
||||||
opts = parser.parse_args(args)
|
opts = parser.parse_args(args)
|
||||||
|
|
||||||
answers = load_answers()
|
answers = load_answers()
|
||||||
@@ -163,6 +169,8 @@ def run(args=None):
|
|||||||
wager = Wager()
|
wager = Wager()
|
||||||
state = {"secret": None, "guesses_made": 0}
|
state = {"secret": None, "guesses_made": 0}
|
||||||
|
|
||||||
|
maybe_check_nyt_word(solver, skip=opts.no_nyt_check, force=opts.force_nyt_check)
|
||||||
|
|
||||||
print("Wordle hard-mode assistant. Type 'help' for instructions.")
|
print("Wordle hard-mode assistant. Type 'help' for instructions.")
|
||||||
print(f"Wager prices: {wager.prices_str()}")
|
print(f"Wager prices: {wager.prices_str()}")
|
||||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@ inlay
|
|||||||
inlet
|
inlet
|
||||||
inner
|
inner
|
||||||
input
|
input
|
||||||
|
intel
|
||||||
inter
|
inter
|
||||||
intro
|
intro
|
||||||
ionic
|
ionic
|
||||||
@@ -1607,6 +1608,7 @@ relax
|
|||||||
relay
|
relay
|
||||||
relic
|
relic
|
||||||
remit
|
remit
|
||||||
|
remix
|
||||||
renal
|
renal
|
||||||
renew
|
renew
|
||||||
repay
|
repay
|
||||||
|
|||||||
@@ -4835,7 +4835,6 @@ inrun
|
|||||||
insee
|
insee
|
||||||
inset
|
inset
|
||||||
inspo
|
inspo
|
||||||
intel
|
|
||||||
intil
|
intil
|
||||||
intis
|
intis
|
||||||
intra
|
intra
|
||||||
@@ -8950,7 +8949,6 @@ remap
|
|||||||
remen
|
remen
|
||||||
remet
|
remet
|
||||||
remex
|
remex
|
||||||
remix
|
|
||||||
remou
|
remou
|
||||||
renay
|
renay
|
||||||
rends
|
rends
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Silent, once-a-day check of the NYT Wordle answer against our word list.
|
||||||
|
|
||||||
|
NYT's `svc/wordle/v2` endpoint discloses the solution for a given date as
|
||||||
|
soon as the local calendar rolls to that date. On startup we check today's
|
||||||
|
date once per day and add the word via the same mechanism as `/addword` if
|
||||||
|
it's missing from `answers.txt`. Never prints anything and never raises —
|
||||||
|
failures are silently ignored and retried on the next run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .game import score_guess
|
||||||
|
from .solver import WORD_LEN, Solver
|
||||||
|
from .words import DATA_DIR, add_answer_word
|
||||||
|
|
||||||
|
STATE_PATH = Path.home() / ".wordle_solver" / "state.json"
|
||||||
|
NYT_URL_TEMPLATE = "https://www.nytimes.com/svc/wordle/v2/{date}.json"
|
||||||
|
REQUEST_TIMEOUT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def get_state_path() -> Path:
|
||||||
|
return STATE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(state_path: Path = STATE_PATH) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(state_path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict, state_path: Path = STATE_PATH) -> None:
|
||||||
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
state_path.write_text(json.dumps(state))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_todays_answer() -> str | None:
|
||||||
|
url = NYT_URL_TEMPLATE.format(date=date.today().isoformat())
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=REQUEST_TIMEOUT) as resp:
|
||||||
|
body = json.loads(resp.read())
|
||||||
|
word = body["solution"].strip().lower()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if len(word) != WORD_LEN or not word.isalpha():
|
||||||
|
return None
|
||||||
|
return word
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_check_nyt_word(solver: Solver, data_dir: Path = DATA_DIR,
|
||||||
|
state_path: Path = STATE_PATH,
|
||||||
|
skip: bool = False, force: bool = False) -> str | None:
|
||||||
|
if skip:
|
||||||
|
return None
|
||||||
|
|
||||||
|
today = date.today().isoformat()
|
||||||
|
state = load_state(state_path)
|
||||||
|
if not force and state.get("last_checked") == today:
|
||||||
|
return None
|
||||||
|
|
||||||
|
word = fetch_todays_answer()
|
||||||
|
if word is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state["last_checked"] = today
|
||||||
|
save_state(state, state_path)
|
||||||
|
|
||||||
|
if word in solver.answers:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = add_answer_word(word, data_dir)
|
||||||
|
solver.answers.append(word)
|
||||||
|
solver.answers.sort()
|
||||||
|
if result["removed_from_guesses"]:
|
||||||
|
solver.extra_guesses.remove(word)
|
||||||
|
|
||||||
|
if all(score_guess(rec.guess, word) == rec.pattern for rec in solver.history):
|
||||||
|
solver.candidates.append(word)
|
||||||
|
|
||||||
|
return word
|
||||||
Reference in New Issue
Block a user