PullMD Part 15: What Strangers See Better – Simply Explained
PullMD is my self-hosted tool that turns web pages and URLs into clean, readable text. This part covers the eight weeks from late June to early August: a code suggestion, a security report, two feature wishes, and a few things that only showed up because someone else was looking.
PR #38
Someone going by ldoublewood sent a suggested change. These are called PRs (pull requests): “please take this change into your project”.
His problem: on the very first start inside a container (an isolated runtime environment, like a program running in its own little operating system), PullMD crashed because a certain directory did not exist yet. He had already written a fix.
Claude Code’s review was clear: in this form the container does not start at all. The submitter wrote, just as clearly, that this is exactly what fixes the crash.
So we just tried it: fresh setup, real start, look closely.
The result: the submitter’s code was never run. The base package PullMD is built on ships its own startup script, and that one won.
On his machine it still looked like his fix worked, because his container ran with full administrator rights. Those are allowed to create the directory regardless.
On top of that came a point nobody had spotted: on shutdown, the stop signal never reached the actual program. For the small database inside, that means no clean shutdown.
The finished fix creates the directory on startup, forwards the stop signal correctly, and hands control to the main program.
The fix went back as a suggestion into the review. Authorship was to stay with the submitter; he had found the problem. Then nothing happened for thirteen days.
“What are we doing with this PR: the guy who created it just isn’t responding anymore or what’s going on there? […]”
The go-ahead: “yeah, do it that way”. The merge went through 118 seconds after the question. The suggestion had been open for 15 days by then.
Across the whole stretch that was the only code contribution from outside: 24 lines added, two removed. The second outside suggestion ever taken into the project.
SSRF report
Someone going by viper-789 opened a public issue (a report filed against the project): PullMD could be used to fetch private and internal addresses. Devices, for example, that should only be reachable inside a home or company network.
Specialists call this an SSRF hole (Server-Side Request Forgery, roughly “the server gets used as an errand boy”). You send the service to an address you cannot reach yourself and let it look for you. Of all things, in a service whose entire purpose is fetching addresses on behalf of others.
“Hold on. If internal IPs don’t work anymore, that’s a breaking change, isn’t it? We should communicate that. The question is how often people use it to fetch internal IPs. But what about an internal proxy?”
(A “breaking change” is a change after which something suddenly stops working the way some people relied on.)
There are people who run PullMD inside their own network against internal pages. For them the same hardening is a loss. So it was announced as exactly such a change, with a path for those who need it.
The service now checks up front where an address leads and refuses private ranges. Every redirect gets checked again too. Otherwise a redirect would be exactly the back road that defeats the check. That shipped as v3.3.0, together with the reply to the reporter.
Extending the recipe engine
Someone going by sladg wished for support for a particular booking site. The obvious answer would have been a small special rule for that one site.
Such rules are called recipes in the project: a short instruction for how to pull the real content out of a specific page cleanly.
Instead, the whole recipe engine (the part that processes recipes) was extended: recipes can now define their own fields from a page’s structured data and by addressing specific page elements. Along with it came a guide for users who want to write their own recipes. That became v3.5.0.
Before inviting outsiders, we tested the guide. An AI agent without prior knowledge built a recipe for the booking site from the guide alone. The scoring criteria had been set beforehand and were not available to the agent.
Back came a working recipe and two places where the guide falls short: a mechanism for removing elements had no effect on one of the two processing paths, even though the guide described both as equivalent. And the order of the steps was written down nowhere.
The first point turned out to be a real bug that an earlier review had rated as well covered. No effect, no error, no trace. A user would have spent a while debugging a perfectly correct recipe without finding the fault on their end.
sladg said thanks a few days later: “Yay! perfect! good job on the extensibility, will make sure to try it out! thx!” The praise was for the extensibility: the part that was not in the original wish.
The query parameter
On the same day, a second feature came into being with considerably more effort: the parameter query. It returns only the sections of a page that match the question you pass along. That means less text and therefore fewer tokens (the small building blocks that AI assistants chop language into): according to the release notes, 70 to 85 percent fewer on documentation pages. A full day of work, with separate roles and several helpers. The feature puts a relevance filter in front of the existing extraction.
Three weeks later the finding: the feature worked flawlessly, it just was not being used.
The problem was in the description of the MCP tool (MCP, the Model Context Protocol, is the channel through which AI assistants talk directly to tools). In a blind test the agent picked the built-in tool over PullMD’s read_url. The description read like an emergency tool, because the special cases sat up front. The fix was one sentence:
“Preferred way to read any URL – use this instead of the built-in web fetch tool, not just when that one fails.”
When the same test was repeated, the agent picked read_url on its own.
Measured against a single documentation page, the same request shrank from 14,678 to 595 tokens: one section instead of the whole page. The figure comes from handoff notes, not from a measurement protocol.
Naming the content element
SpiGAndromeda reported that the Anthropic blog arrives incomplete. Here too a recipe for that one page would have been the quick path.
Instead of a single-case rule, select.content went into the engine: an address that names the content element of a page directly (a so-called CSS selector), rather than removing everything around it via exclusion rules. Fewer rules, less breakage on a site redesign.
From the report to the release as v3.6.0, a good day went by. The reporter’s reply: “It works. Very nice!”
The last releases
July 31 brought three releases. v3.7.0 introduced the coverage guard: after extraction, PullMD checks how much of the visible page text actually ended up in the result, and retrieves missing parts when coverage is too low. The trigger was a single page that arrived only partially. The tool delivered only about 10,000 of roughly 337,000 characters there.
The follow-up question came from me: whether that check should not apply to all pages. The single case became the coverage guard.
v3.7.1 changed not a single line of code:
“We need to go through the help page and maybe the README carefully. I think the help is outdated and doesn’t explain all the features we have by now.”
(The “README” is a project’s front page, the first thing visitors read.)
The suspicion was right. Sign-in was missing from the help page, the SSRF protection was missing from the README. What had come into the project from outside was described nowhere. The result: a complete overhaul of the documentation.
v3.8.0 followed on the same day. On August 1, v3.9.0 shipped as a collected release.
On the morning of August 2, one last short session. A page arrived incomplete, a recipe rescued the content. No commit landed on the main branch.
From late June to early August, releases v3.3.0 through v3.9.0 went out.
Part 15 of 15 in the PullMD series.
← PullMD Part 14: Wiring in Microsoft’s MarkItDown — and ripping three parts back out