#!/usr/bin/env python3 """Check on lore.kernel.org whether a posting was archived, and list its replies. Why NNTP and not the web UI: since mid-2026 lore.kernel.org (and patchwork.kernel.org) sit behind an Anubis proof-of-work bot check, so plain HTTP from curl or a script gets the "Making sure you're not a bot!" interstitial instead of the message -- HTTP 403 without a browser User-Agent, HTTP 200 with the interstitial body if one is set. The NNTP mirror on nntp.lore.kernel.org:119 serves the same archive with no challenge, so it is the reliable way to answer "was my patch actually published, and did anyone answer" from a shell. Read-only: it opens the group, asks for the article head, and scans the overview records after it. It never posts (the server refuses posting anyway). Usage: ./tools/lore-thread-check.py [--group org.kernel.vger.linux-i2c] The message-id is given without angle brackets, e.g. ./tools/lore-thread-check.py 20260808212414.217511-1-simone.chifari@gmail.com Exit status: 0 if the message is in the archive, 1 if it is not, 2 on error. """ import argparse import socket import sys DEFAULT_GROUP = "org.kernel.vger.linux-i2c" HOST = "nntp.lore.kernel.org" PORT = 119 HEADERS_OF_INTEREST = ( "from", "to", "cc", "subject", "date", "message-id", "in-reply-to", "references", "x-mailing-list", ) class Nntp: def __init__(self, host, port, timeout=60): self.sock = socket.create_connection((host, port), timeout=timeout) self.fp = self.sock.makefile("rb") self.banner = self._line() def _line(self): return self.fp.readline().decode("utf-8", "replace").rstrip("\r\n") def cmd(self, text): self.sock.sendall((text + "\r\n").encode()) return self._line() def block(self): out = [] while True: line = self._line() if line == ".": return out out.append(line[1:] if line.startswith("..") else line) def close(self): try: self.cmd("QUIT") finally: self.sock.close() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("msgid", help="message-id without angle brackets") ap.add_argument("--group", default=DEFAULT_GROUP, help="lore NNTP group (default: %(default)s)") ap.add_argument("--scan", type=int, default=0, metavar="N", help="also scan the N articles before the message " "(default: only the ones after it)") args = ap.parse_args() msgid = args.msgid.strip().strip("<>") try: nntp = Nntp(HOST, PORT) except OSError as exc: print(f"cannot reach {HOST}:{PORT}: {exc}", file=sys.stderr) return 2 try: nntp.cmd("MODE READER") resp = nntp.cmd("GROUP " + args.group) if not resp.startswith("211"): print(f"GROUP {args.group}: {resp}", file=sys.stderr) return 2 _, _, low, high, _ = resp.split(None, 4) resp = nntp.cmd(f"HEAD <{msgid}>") if not resp.startswith("221"): print(f"NOT in {args.group}: <{msgid}> ({resp})") return 1 article = resp.split()[1] print(f"archived: article {article} of {args.group} (range {low}-{high})") for line in nntp.block(): if line[:1] in (" ", "\t"): continue if line.split(":", 1)[0].lower() in HEADERS_OF_INTEREST: print(" ", line) first = max(int(low), int(article) - args.scan) resp = nntp.cmd(f"XOVER {first}-{high}") if not resp.startswith("224"): print(f"XOVER: {resp}", file=sys.stderr) return 2 replies = [] for line in nntp.block(): f = line.split("\t") if len(f) < 6: continue num, subject, sender, date, this_id, refs = f[:6] if num == article: continue if msgid in refs or msgid in this_id: replies.append((num, date, sender, subject)) print(f"\nsame thread, articles {first}-{high}: {len(replies)}") for num, date, sender, subject in replies: print(f" {num} | {date} | {sender} | {subject}") if not replies: print(" (none -- no reply and no follow-up patch of this thread)") return 0 finally: nntp.close() if __name__ == "__main__": sys.exit(main())