The tooling I use to draft has a web-fetch capability. Point it at a URL, it returns the content. It is convenient, it works on most of the internet, and for about two months it was how my drafting pipeline read Minnesota statutes.
It was producing wrong quotations. Not fabricated ones — wrong ones. The cite was right, the section was right, the words were real words from that section, and the quoted passage ended somewhere the statute did not.
The cause was structural rather than occasional. That fetch layer does not return the page. It reads the page and returns a model-processed rendering of it. On a long statutory section it silently drops material. What comes back reads like the statute, has the cadence of the statute, and is not the statute. Nothing in the output announces that anything was left out, because from the tool’s point of view nothing went wrong.
This is the single most important thing I have learned building legal tooling: a summarizing fetch layer is disqualifying for primary law. Not “use with care.” Disqualifying. The correct move is to fetch the raw bytes and parse them yourself.
What follows is that parser, the four bugs it exposed, and an honest account of what it cost.
Everything below was executed against the live Revisor site while writing this article. No client matter, file, or document appears anywhere in it. The statutory material is used to demonstrate parsing technique, not to state what Minnesota law currently is — check that yourself, at the source.
Rule one: fetch bytes, parse yourself
import urllib.request
BASE = "https://www.revisor.mn.gov"
UA = "MadgettLaw-StatuteFetch/1.0 (internal research; 612-470-6529)"
def raw(url, timeout=20):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
Six lines, and the difference between them and a summarizing fetch is the difference between a research tool and a plausible-text generator. Set a real User-Agent — one that says who you are and how to reach you. Set a timeout. Be a good citizen of somebody else’s server.
Now the parsing, which is where it gets interesting.
Bug 1: the heading that is not a heading
The first thing you want from a statute page is the official section heading. On revisor.mn.gov/statutes/cite/<cite> it lives in exactly one place:
<h1 class="shn">604.02 APPORTIONMENT OF DAMAGES.</h1>
shn — section heading. One occurrence per page. Anchor on it:
import re, html
H1_SHN = re.compile(r'<h1[^>]*class="[^"]*\bshn\b[^"]*"[^>]*>(.*?)</h1>', re.S)
def detag(fragment):
fragment = re.sub(r"<[^>]+>", "", fragment)
return html.unescape(re.sub(r"\s+", " ", fragment)).strip()
That is reliable. Here is what is not reliable, and it is the obvious first thing anyone writes — strip the tags, then find the cite number and grab what follows it:
text = re.sub(r"<[^>]+>", " ", body)
text = re.sub(r"\s+", " ", text)
m = re.search(r"604\.02\s+(.{0,60}?\.)", text)
print(m.group(1))
I ran that against the live page while writing this paragraph. It prints:
MN Statutes window.
That is not a corrupted heading. That is the page’s <title> element — “Sec. 604.02 MN Statutes” — immediately followed by the first line of an inline analytics script, window.dataLayer = window.dataLayer || []. Tag-stripping flattened <title>, <script>, and the navigation menu into the same stream of words as the statute, and the first thing matching the pattern was page furniture.
The failure mode is worth sitting with. It did not error. It returned a string. A string of the right rough shape, in the right rough place, that a caller would happily store in a section_title field and print in a memo header.
Which brings up the discipline that catches this class of bug: validate your extractor against a section whose heading you already know, and assert on the exact value. Not “does it return something.” Does it return this.
def test_heading():
s = get_section("604.02")
assert s["heading"] == "604.02 APPORTIONMENT OF DAMAGES.", s["heading"]
Cheap, and it fails loudly on the day the Revisor changes its markup — which it will.
Bug 2: the section that does not exist
A cite that does not exist returns HTTP 404, and the body contains a specific phrase:
<h1>Minnesota Statutes</h1>
<p>Statute could not be found.</p>
Both signals are worth checking, because a site can serve a “not found” page with a 200 status and this one has no obligation to keep returning 404 forever. I check status and phrase and the absence of class="shn".
I ran this over a batch of cites drawn from draft articles. It caught six section numbers that do not exist. Not one of them looked wrong. They were all well-formed Minnesota cites in plausible chapters, sitting in sentences that read fine.
An existence screen is the cheapest high-value check in this entire category, and it becomes nearly free once you cache:
def screen(cites):
return {c: get_section(c)["exists"] for c in cites}
Cold, over six cites, that took 4.1 seconds. Warm, against the cache, 0.0015 seconds. That is the whole argument for caching: it turns “screen this article” into “screen the entire corpus, on every save, forever.”
The cache, which is also the politeness layer
import hashlib, json, time, urllib.error
from pathlib import Path
CACHE = Path.home() / ".cache" / "mnstat"
CACHE.mkdir(parents=True, exist_ok=True)
_last_request = 0.0
MIN_INTERVAL = 0.5
def fetch(url, *, timeout=20, max_age=86400):
"""Return (status, body). Disk-cached. Caches 404s too."""
key = hashlib.sha256(url.encode()).hexdigest()[:32]
path = CACHE / f"{key}.json"
if path.exists() and (time.time() - path.stat().st_mtime) < max_age:
d = json.loads(path.read_text())
return d["status"], d["body"]
global _last_request
wait = MIN_INTERVAL - (time.time() - _last_request)
if wait > 0:
time.sleep(wait)
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
status, body = r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
status, body = e.code, e.read().decode("utf-8", "replace")
finally:
_last_request = time.time()
path.write_text(json.dumps({"url": url, "status": status, "body": body}))
return status, body
Four design decisions in there, each of which I got wrong first:
Cache the raw body, not the parsed result. When you fix a parser bug — and you will — you want to re-parse a thousand cached pages without touching the network.
Cache the 404s. A negative result is a result. If you only cache successes, every re-run re-requests every phantom cite, which is both the slow path and the rude path.
Rate-limit in the fetch function, not at the call site. A limiter you have to remember to call is a limiter that will not be called.
A short max_age. One day. Primary law moves, and a cache with no expiry is a currency bug with a performance benefit.
On parallelism: I have had parallel fetches against this source fail where sequential ones with a modest timeout succeeded. When I re-ran the experiment for this article — twelve cites at eight workers — it completed cleanly in one second with zero errors. I am reporting both results because the inconsistency is the finding. Throughput against somebody else’s server is a thing you do not control and should not depend on. The cache is what makes that irrelevant, which is a better answer than tuning a worker count.
Bug 3: currency, which is the hard one
Everything above is mechanical. This one is a genuine correctness trap, and it will not show up in your testing.
The Revisor displays a specific annual edition. Every section page carries it:
<h1>2025 Minnesota Statutes</h1>
Parse it. Store it. Print it in anything your tool outputs.
Because the on-screen body text is that edition’s text, and a later session may have changed it. When it has, the page carries a banner. Here is one I pulled live while writing this:
<div id="warning" class="alert alert-warning" role="alert">
This section has been affected by law enacted during the 2026 Regular Session.
<a id="more_info_link" href="#">More info...</a>
<div id="more_info">
<ul>
<li>169.444 subd. 1 has been amended by
<a href="/laws/2026/0/41/laws.0.1.0">Chapter 41, Section 1</a></li>
</ul>
</div>
</div>
Read that carefully. The body of the page below that banner is the 2025 text. It is pre-amendment. A tool that fetches the section, extracts the paragraphs, and stops has just quoted superseded language with a correct citation attached — and it will pass every test you wrote, because the text it returned genuinely is on the page.
So parse the banner as a first-class field and refuse to be quiet about it:
WARNING = re.compile(r'<div id="warning"[^>]*>(.*?)</div>\s*</div>', re.S)
AMENDED_BY = re.compile(
r"([\d.]+[A-Z]?[\d.]*)\s+(subd\.\s*[\w.]+\s+)?has been amended by\s*"
r'<a href="(/laws/[^"]+)">([^<]+)</a>', re.S)
Against § 169.444 that returns:
{'cite': '169.444', 'subd': 'subd. 1',
'url': 'https://www.revisor.mn.gov/laws/2026/0/41/laws.0.1.0',
'label': 'Chapter 41, Section 1'}
Now a downstream consumer can be made to refuse: if pending is non-empty and the caller did not explicitly acknowledge it, raise. Do not warn. Raise. A warning in a log is a warning nobody read.
One more trap in the same neighborhood. Each section page has a sidebar panel headed “Recent History.” It is abbreviated, and it is not a currency check. For § 604.02, that panel lists exactly one entry — a 2003 amendment. The section’s actual History: line, in the body, reads:
1978 c 738 s 8; 1986 c 444; 1986 c 455 s 85; 1988 c 503 s 3;
1989 c 209 art 1 s 44; 2003 c 71 s 1
Six acts in the history, one in the panel. The panel is a convenience feature and it is doing its job. If you treat it as the amendment record, that is your bug and not theirs. Parse the History: block, follow the version list, and understand that none of this is a substitute for actually shepardizing.
Bug 4: never strip tags off a session law
This is the technique I would most want another lawyer to steal.
Minnesota session laws are published in struck-and-inserted form: the amended section is shown with deleted language struck through and new language underlined. That markup carries the entire meaning of the document. Strip the tags — the obvious first move, and the one every generic HTML-to-text utility makes — and you destroy exactly the information you came for.
Watch. Here is 2003 c 71 s 1, tags stripped, rendered by a naive pipeline:
Subdivision 1. [JOINT LIABILITY.] When two or more persons are jointly severally liable, contributions to awards shall be in proportion to the percentage of fault attributable to each, except that each is the following persons are jointly and severally liable for the whole award. Except in cases where: (1) a person whose fault is greater than 50 percent; …
The doubled words (“jointly severally,” “each is the following persons are”) are visible garbage, which is almost lucky. The real damage is further down, where several sentences that the act deleted survive the strip intact and read as operative law with no marker of any kind. A model handed that text will summarize the deleted rules as current. It has no way not to.
Preserve the markup and parse it instead:
def render(fragment, version):
"""version: 'before' = pre-amendment law, 'after' = as amended."""
if version == "before":
fragment = re.sub(r"<u>.*?</u>", "", fragment, flags=re.S) # drop insertions
fragment = re.sub(r"</?s>", "", fragment) # keep deletions
else:
fragment = re.sub(r"<s>.*?</s>", "", fragment, flags=re.S) # drop deletions
fragment = re.sub(r"</?u>", "", fragment) # keep insertions
return detag(fragment)
Same input, run through render(..., "before"):
When two or more persons are jointly liable, contributions to awards shall be in proportion to the percentage of fault attributable to each, except that each is jointly and severally liable for the whole award. Except in cases where liability arises under chapters 18B … a person whose fault is 15 percent or less is liable for a percentage of the whole award no greater than four times the percentage of fault, including any amount reallocated to that person under subdivision 2.
And render(..., "after"):
When two or more persons are severally liable … except that the following persons are jointly and severally liable for the whole award: (1) a person whose fault is greater than 50 percent; (2) two or more persons who act in a common scheme or plan that results in injury; (3) a person who commits an intentional tort; or (4) a person whose liability arises under chapters 18B …
That is a clean, mechanical before-and-after of a statutory amendment, produced from the primary source in about fifteen lines. It is how I confirmed that two Minnesota tort propositions I had seen repeated in secondary material — the four-times-fault cap for a defendant at 15 percent or less, and the separate rule for a jointly liable state or municipality under 35 percent fault — were both struck by that 2003 act. Both appear in the deleted list. Neither appears in the current section.
I am not telling you what Minnesota comparative-fault law is today; go read § 604.02 and its subsequent history yourself. I am telling you that a tool which strips tags before parsing would have told me those rules were still on the books, fluently, with a correct citation.
The trap inside the trap
The markup is not the same across years. Chapters from 2005 and earlier use a <pre> block with <s> and <u>. Chapters from 2006 forward use a different scheme:
<span class="sr-only">deleted text begin </span>
<span style="text-decoration: line-through" class="del">an extended stop-signal arm and</span>
<span class="sr-only">deleted text end </span>
<span class="sr-only">new text begin </span>
<ins style="text-decoration: underline"> must</ins>
<span class="sr-only">new text end </span>
Sampling chapters on both sides put the changeover between the 2005 and 2006 sessions. Treat that as the boundary I observed rather than a guarantee about every chapter in every year — which is itself the reason the parser detects the format instead of switching on the year:
OLD_DEL = re.compile(r"<s>(.*?)</s>", re.S)
OLD_INS = re.compile(r"<u>(.*?)</u>", re.S)
NEW_DEL = re.compile(r'<span[^>]*class="del"[^>]*>(.*?)</span>', re.S)
NEW_INS = re.compile(r"<ins[^>]*>(.*?)</ins>", re.S)
def session_law_changes(chapter_url):
status, body = fetch(chapter_url)
if status != 200:
raise RuntimeError(f"{chapter_url}: HTTP {status}")
body = re.sub(r'<p class="key">.*?</p>', "", body, flags=re.S) # drop the legend
body = re.sub(r'<span class="sr-only">.*?</span>', "", body, flags=re.S)
if 'class="del"' in body or "<ins" in body:
fmt, dels, inss = "modern", NEW_DEL, NEW_INS
else:
fmt, dels, inss = "legacy", OLD_DEL, OLD_INS
return {"format": fmt,
"deleted": [detag(x) for x in dels.findall(body) if detag(x)],
"inserted": [detag(x) for x in inss.findall(body) if detag(x)]}
Two details that cost me time. Both formats include a legend paragraph — Key: (1) language to be deleted (2) new language — which uses the very markup you are searching for and will appear as a phantom edit in every result. Strip it first. And the modern format wraps each edit in screen-reader-only spans saying “deleted text begin” and “deleted text end”; leave those in and your extracted text is littered with them.
Yes, this is brittle. That is the feature.
Everything above depends on the Revisor’s HTML staying roughly as it is. It will not, forever. Some morning class="shn" becomes class="section-heading" and my parser stops working.
Here is what happens on that morning:
if not parsed:
raise RuntimeError(
f"{cite}: HTTP {status} but no <h1 class='shn'> found. "
"The Revisor's markup changed; fix the parser before trusting output.")
It raises. Loudly, immediately, in my face, with the reason and the fix. Nothing downstream gets a wrong answer, because nothing downstream gets an answer.
Compare the failure mode of the thing I replaced. A summarizing fetch layer never breaks. It degrades — gradually, invisibly, differently on each call, in proportion to how long the input was. It produces something every single time, and the something is confident, well-formed, and occasionally short by two clauses in the middle of the sentence you were about to quote to a court.
A scraper that breaks loudly is strictly safer than a summarizer that degrades quietly. An afternoon of maintenance is a known, bounded, scheduled cost. A quotation that ends one clause early is an unbounded one, and you will not find out about it from the tool.
I would rather fix a regex twice a year.
Sources
Every claim below was verified by direct HTTP fetch against the live site on the date of this article, not taken from documentation or memory. Code snippets were executed; the outputs shown are actual outputs.
- Minn. Stat. § 604.02 — heading in
<h1 class="shn">,2025 Minnesota Statutesedition marker,History:block, “Recent History” sidebar panel showing one of six listed acts - Minn. Stat. § 169.444 — live example of the
<div id="warning">pending-amendment banner over 2025 edition body text - 2003 Minn. Laws ch. 71 — legacy
<pre>/<s>/<u>struck-and-inserted format; source of the before/after rendering above - 2026 Minn. Laws ch. 41 — modern
class="del"/<ins>/sr-onlyformat - Nonexistent-cite behavior (HTTP 404 plus “Statute could not be found”) confirmed against four invented cites
- Session law markup format boundary observed between the 2005 and 2006 sessions by sampling chapters on both sides (legacy
<s>/<u>through 2005;class="del"/<ins>from 2006 forward)
General commentary on legal technology, not legal advice and not a statement of current Minnesota law. Statutory text is reproduced to illustrate parsing technique; verify any rule at the source before relying on it. No client information appears in this article — all cites used as examples were chosen for their markup, not from any matter. Questions about anything here: Send us a message or 612-470-6529.