All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Self-hosters should consult MIGRATION.md when upgrading across a major version.
3.9.0 - 2026-08-01
- Download button in the PWA. A third action in the result toolbar, beside Copy and Share: it writes the Markdown as currently shown (the frontmatter toggle is respected) to a file and saves it under the name the server suggests. Unlike Share it needs no browser capability check, so it is visible whenever a result is. Three buttons no longer fit a phone header, so the existing 480px breakpoint now collapses all three to icon-only; their accessible names survive as
title/aria-labelthrough the existing translation mechanism, so they stay readable to a screen reader and stay translated. - The server now suggests the download filename (
X-Suggested-Filename). The name used to be derived in the browser from the frontmatter title alone, which gave a YouTube video its cryptic id as a name and threw away the original basename of an uploaded image or document. The server knows the extraction source, the URL and the title, so it builds the suggestion instead: YouTube becomesYT-<title>-<video-id>, the file-based sources (image-caption,audio-transcript,markitdown,pdf-ocr) keep the basename of the source file, and everything else uses the title slug, with a title → URL basename → share id →pullmdfallback chain and a final pass that leaves only characters an HTTP header and a filesystem both accept. The header is set onGET /api(fresh and cached),POST /api/html,POST /api/fileandGET /s/:id;GET /api/streamcarries the same value as asuggestedFilenamefield on theresultevent, because SSE headers are flushed long before the result exists and the SSE path is what the PWA uses. The newPULLMD_FILENAME_DATE_PREFIXprepends a rendered date to every name (tokensYYYY,MM,DD,HH,mm,ss, local time, every other character passes through); it is unset by default, so nothing changes unless you ask for it. The PWA prefers the server suggestion and keeps its own client-side chain as a fallback against an older server.
queryis now described by when to use it, not by how it works. The MCPread_urlschema, the bundled Claude Code skill, the README and the in-app help page all described the parameter mechanically ("return only the sections relevant to this text", "BM25 over the converted Markdown"), which told an agent what the feature does but never that it should reach for it. In practice the parameter went unused: a model reading the old description had no trigger condition to match against. All four surfaces now lead with the trigger - when you need specific information from a page rather than the whole document, pass the question you are trying to answer, in natural language - name the payoff (typically 70-95% fewer tokens on long pages), and frame the full-page fetch as the case that needs a reason (summarizing, translating, archiving).max_tokensadditionally states that it has no effect withoutquery. Text only: no parameter, default, validation or response shape changed, and nothing changes for an existing integration except how likely an agent is to use the parameter at all.read_url's description now opens with a precedence rule. It states up front that this is the preferred way to read any URL and should be used instead of the built-in web fetch tool, not just when that one fails. Previously the description led with a capability list (SPAs, Reddit, Cloudflare, documents), which read as "fallback for hard pages" and left an agent with a native fetch tool no reason to prefer PullMD for ordinary URLs. The capability list and the rest of the text are unchanged; only the order changed. Nothing changes for existing integrations except the likelihood the tool gets chosen.PULLMD_LLM_MODELnow says out loud that it is not a variable. The sharedPULLMD_LLM_*fallback covers the API key and the base URL but not the model, because one name cannot be a vision chat model, a speech model and an OCR model at once. That asymmetry was invisible: settingPULLMD_LLM_MODELlooked exactly as reasonable as the two variables next to it, was read by nobody, and left every modality on its own default with no hint as to why. The server now warns at startup when it is set and names the three variables that do work (PULLMD_VISION_MODEL,PULLMD_STT_MODEL,PULLMD_PDF_OCR_MODEL); the README and.env.examplestate the limit where the fallback is introduced instead of leaving it to be inferred.
- The rendered view no longer turns the frontmatter into a giant heading.
markedreadskey: valuefollowed by---as a setext heading, so with the frontmatter toggle on, the rendered view opened with a title made of metadata. Only the body goes throughmarkednow; the frontmatter is prepended as a plain, quieter code box, filled viatextContentso a user-controlled title or URL is never parsed as markup. - The result toolbar's actions stay right-aligned when the row wraps.
space-betweenleft-aligns a lone wrapped flex item, so on a narrow screen Copy/Share/Download dropped to a second line and sat on the left; they are now pinned to the right edge on every line. - Image captioning worked against no current OpenAI model. The request sent
max_tokens, which those models reject outright ("Unsupported parameter ... usemax_completion_tokensinstead"), so every caption failed with a 400 and the conversion degraded quietly to plain extraction: pointingPULLMD_VISION_MODELat anything recent produced no captions at all. Switching unconditionally was not an option either, because many OpenAI-compatible endpoints only understandmax_tokens. The request therefore still asks the way everything understands and retries once withmax_completion_tokenswhen the endpoint says that is what it wants. Deciding from the model name was rejected on purpose: any such list is stale by the next model release. - Reasoning models returned an empty caption.
max_tokenscaps visible output,max_completion_tokenscaps visible output plus the reasoning tokens a reasoning model never shows, so reusing the same 500 meant the model spent its whole budget thinking and returned an empty message. Measured against a current small reasoning model, 500 and 1000 both came backfinish_reason=lengthwith zero characters of text, while the first usable caption needed around 1600 completion tokens, 1472 of them reasoning. The retry path now asks for 4000, which costs nothing when unused because only generated tokens are billed. An empty caption is no longer handed back either: it used to yield a## Descriptionheading with nothing under it, still labelled as the image-caption source, where a throw is caught, logged, and falls back to markitdown. - The admin CLI silently did nothing when stdin was already at EOF.
docker execwithout-ihands the process exactly that, andreadline'squestion()never settles on it, socreate-userandreset-passwordprinted "New password:" and exited 0 having created or changed nothing - the most natural way to invoke the CLI against a running container was also the one silent failure mode it had. The non-interactive branch now reads the stream directly instead of going throughreadline, which fixes a second case in passing: a piped password without a trailing newline used to be dropped the same silent way, becausereadlinedoes not hand over a final line that never terminates. CRLF is stripped. An empty stdin raises an operator-facing error naming three working invocations and exits 2 without a stack trace. The interactive TTY path is untouched.
- The help page documents user management. The admin CLI existed only in the README and the changelog, so an operator looking at their own instance had no way of finding out that accounts are created with
scripts/admin.js. The authentication section now listslist-users,create-user,make-adminandreset-password, states that there is no UI for any of it, and names the invocations that actually deliver a password on stdin. - README, help page and skill bundle audited against this batch.
X-Suggested-Filenamewas missing from the README's response-header list and from the skill's "headers worth checking"; the download button was missing from the README's feature list; the README documentedcreate-user/reset-passwordwithout a word on the stdin requirement. The universal agent prompt, which ships in both the README and the help page, opened with "instead of raw HTML" - a phrasing an agent holding a native browse tool does not match on, which is the same failure theread_urldescription fixed; both copies now name the built-in fetch tool and reject the fallback-only reading.
3.8.0 - 2026-07-31
PULLMD_ALLOW_SIGNUPcloses self-registration inmulti-usermode. Default is on, so an existing instance behaves exactly as before; onlyfalse,0,nooroffturns it off. When off, the/signuproutes are not mounted at all, so both GET and POST answer 404 and no account can be created by a crafted request, the login page drops its "create an account" link, and/api/configreportssignupOpen: false. This is for running a public demo instance inmulti-usermode without collecting stranger accounts.createAuthexposes the raw switch asallowSignupand the effective value assignupOpen(mode === 'multi-user' && allowSignup); only the effective value is read anywhere in production code, because a switch that is on while the mode has no signup route at all is not useful information.node scripts/admin.js create-user <email>with a password prompt, so "registration closed" is not a state an operator cannot get out of. The account is created without admin rights;make-adminpromotes it as a separate step.
- Cache deletes are now scoped to the caller instead of being rejected (closes #49).
DELETE /api/cache/:idandDELETE /api/cacheused to sit behind an admin-only guard, so a logged-in non-admin got403 {"error":"Admin required"}and could never clear anything from their own history, even though the frontend offered them the button. The guard's premise was sound only halfway:conversionsis deduplicated by URL and therefore shared between all users, so dropping a row does affect everyone - but each user's history is a separateuser_fetchesjoin table, and unlinking a row there affects nobody else. An admin (and every caller whenPULLMD_AUTH_MODEisdisabledor unset) still purges the shared row; a regular user now only unlinks their own history entry, leaving the shared row and its/s/:idshare link intact. Both responses gainedscope: "user" | "global", and delete-all gainedremoved. Existing status codes are unchanged. For API consumers: insingle-adminandmulti-usermodes a non-adminDELETE /api/cache*now answers200withscope: "user"where it previously answered403. - The delete button says which of the two it does. The same
×now means "remove from my history" or "delete globally" depending on the caller, so the tooltip and the archive's delete-all confirmation name the scope. When the caller's role cannot be determined the label errs toward "global", because claiming a global delete for one that turns out to be scoped costs nothing, while the reverse puts a harmless label on a destructive action.
- Orphaned history rows inflated the archive's entry count.
user_fetches.cache_idhas no foreign key toconversions, andpruneOld- which runs on every conversion and drops entries older than 90 days - never removed the matching history rows. SincecountForUsercounts without the join thathistoryPageForUseruses, the archive reported more entries than it could ever return and paginated toward entries that never arrived, drifting further with every prune. Cleanup is now explicit in the delete paths and after a prune that actually removed something, plus a one-time sweep when the cache opens, so an existing database repairs itself on the next start. A real foreign key withON DELETE CASCADEwas deliberately not introduced: better-sqlite3 only enforces foreign keys withPRAGMA foreign_keys=ON, and enabling that globally would start enforcing constraints across the OAuth and session tables too. - A failed delete is now visible. Both entry-delete handlers turned a failed request into a red border and a
titleattribute and never called the page's own error banner, so a rejected delete looked like a dead button; the archive's delete-all had no failure path at all. All three now surface the failure. This also required fixing the banner itself: opening the archive view hides the shared error element with an inline style that outranks the class the banner is shown with, so a message raised from inside the archive would not have rendered. reset-passwordandcreate-usersilently did nothing when the password came from a pipe.scripts/admin.jsreads passwords throughnode:readline/promises, whosequestion()returns a promise and ignores a callback, but the non-interactive branch passed one. The promise never settled, the process exited 0, and nothing was written - so a scriptedreset-passwordreported success without changing anything, for as long as that command has existed. Interactive use was never affected. A test that drives the real CLI as a child process with piped stdin now guards it.- Read-only admin CLI commands no longer write to the database.
list-users,reset-passwordandmake-adminran the auth migration on startup. Because the CLI defaults tomulti-userwhile the server defaults todisabled, on an auth-disabled instancelist-userseither aborted with a stack trace or silently bootstrapped an admin user from whatever credentials were in the environment, claimed every existing conversion for it and backfilled history rows. - The login page no longer links to a page that does not exist. The "create an account" link was rendered in every auth mode, but
/signuponly exists inmulti-user, so insingle-adminthe link led to a 404. It is now tied to whether the route is actually reachable.
3.7.1 - 2026-07-31
- The in-app help page now documents the service as it ships (#48). It had drifted to a v3.0 view: the extraction stack was described as "Readability + Turndown" (a dependency replaced by
node-html-markdown, with Trafilatura and the headless-Chromium stage missing from the list entirely), andcomments/comment_depthwere still presented as Reddit-only although Hacker News has honored them since 3.1. New sections cover the endpoints (all 13, including/api/stream, archive, storage, stats, config and recipes status), the response headers (includingX-Transcript-Statusand the fiveX-Extract-*headers), query extraction, site recipes, and authentication. That last gap was the practical one:/helpstays public whenPULLMD_AUTH_MODEis set, but it never mentioned login, API keys or OAuth, so an instance could reject every API call without telling anyone how to authenticate. "Local HTML files" grew into a "converting files" section covering/api/html,/api/file, document URLs and images/audio/YouTube; the parameter table gainedrender,extractor,pdf,yt_timecodes,yt_chunkandmax_tokens; and the drop-in agent prompt was synced with the README version plusquery. - README: SSRF protection documented. The guard shipped in 3.3.0, but the README never mentioned it, so the default-deny behavior and the
PULLMD_ALLOWED_HOSTSopt-out were only discoverable from the changelog or.env.example. Also addsPULLMD_ALLOWED_HOSTSandPULLMD_SITE_RECIPESto the configuration table,/api/configand/api/recipes/statusto the endpoint table,query/max_tokens/pdf=ocrto the universal prompt, and a summary of what the 3.x line added after 3.0. - Bundled Claude Code skill: Hacker News (pipeline entry and
X-Sourcevalue),query/max_tokenswith an example and a usage tip,X-Transcript-Status, and theX-Extract-*headers.
3.7.0 - 2026-07-31
- Coverage guard — extractions that keep only a sliver of the page now recover the rest (closes #46). Readability commits to a single best candidate container. On the one-pagers page builders emit, the article is a flat list of sibling blocks whose prose sits several levels deep; Readability's candidate score dilutes with nesting depth, so a shallow paragraph-rich block near the top outscores the far larger content below it, and everything else is dropped. The output is well-formed markdown, so nothing downstream signals the loss — measured on one such page, 10,503 of 336,670 characters, 3.1% coverage. The guard now re-converts the container that holds the body instead, which is an auto-written
select.contentthrough the same conversion path a recipe takes. It fires only when all of the following hold: no recipeselect.content, no forced static extractor, a cleaned body of at least 20,000 characters, coverage under 10%, a single container dominating the body text, and a recovered candidate at least 3× larger with at least 5 paragraphs. The dominance condition is what separates one article split across siblings from many separate teasers, and it is what keeps listing pages out. Calibrated against an 85-page live corpus (forum threads, comment-heavy blogs, wikis, long single-document pages, shops, listing homepages, landing pages): it fires on 1 of 85 and on 0 of 12 forum threads, with roughly a factor of 2 between the shipped thresholds and the first false positive. The guard can only grow a result, degrades to previous behavior on any error, and is switched off with the newPULLMD_COVERAGE_GUARD=off. Every intervention carriessource: coverage-guardand records what it acted on inmetadata.extractorReason— coverage, body size, gain, and the share of the body the recovered container actually holds. Aselect.contentrecipe still wins outright, so a site that needs finer cleanup can keep one.
3.6.0 - 2026-07-26
select.content— recipes can now name the article body outright. Until now the recipe engine was purely subtractive (select.remove,preprocess): a recipe could say what to throw away, but never what the article is, leaving the final choice to Readability's candidate scoring.select.contenttakes a list of CSS selectors, joins every match in document order into a single document, and uses that as the body — skipping both the Readability scoring and the Trafilatura auto-pick. Nested matches collapse to the outermost, invalid selectors skip themselves, andselect.removestill applies first, so the two compose. Output carriessource: recipe-content. Because a stalecontentselector would otherwise yield an empty article, a selection under 200 characters falls back to the normal pipeline and records the reason inmetadata.extractorReason. Documented inSITE-RECIPES.md.
- Blog posts whose body is split across sibling containers lost everything outside the winning block (closes #44). Some CMS templates wedge an in-article call-to-action between two separate body containers; Readability scores a single top candidate and keeps only that candidate plus its direct siblings, so the entire lead section was dropped while the output still looked well-formed. A shipped recipe (
claude-blog-split-body) now names both containers via the newselect.content. The Trafilatura auto-pick could not catch this on its own: its output carried the full text but no markdown headings, andpickBestrequires at least one heading before preferring the longer candidate.
- Recipe-defined frontmatter fields. Site recipes can now inject custom frontmatter fields via a new
frontmatterblock, sourced from a page's embedded JSON-LD (<script type="application/ld+json">, selected by schema.org@typeand resolved with a dot-path) or from CSS selectors. Field names are validated (letter-led, ≤ 64 chars); pipeline-computed names (provenance, share/cache bookkeeping, media/LLM-usage) are reserved and reject the recipe, while metadata-derived names (title,author,published,modified,description,language,image,site) are overridable and beat the generic scrape on a collision. WhenPULLMD_FRONTMATTER_FIELDSis set as an allowlist, recipe fields it drops are surfaced in the startup log and in the newfilteredFrontmatterFieldsarray ofGET /api/recipes/status. - Booking.com hotel-listing recipes (
booking-hotel-noise,booking-hotel-frontmatter, closes #40). Hotel pages (/hotel/**) are rendered via Playwright (the raw response is an AWS-WAF challenge shell), page chrome is stripped (header/footer/searchbox, gallery, breadcrumbs, Genius/promo banners, review-avatar flags, payment-card images, lazy-loading Q&A skeleton), and the Hotel JSON-LD is mapped into frontmatter (description,address,rating,rating_best,review_count,price_range). The body keeps the hotel description, popular facilities, room table (with prices when the URL carriescheckin/checkoutdates), house rules and fine print. - Site-recipe contributor guide (
SITE-RECIPES.md), documenting the recipe engine end to end — matching and merge semantics, the full schema, rendered-DOM handling, JSON-LD-to-frontmatter, and the testing/PR workflow — with a pointer added from the README.
- Recipe
select.removenow also applies to the HTML sent to the Trafilatura sidecar — previously it was a silent no-op whenever the quality auto-pick chose Trafilatura, so recipe-removed elements still leaked into the output. Selectors are now applied one by one, so an invalid selector is skipped individually instead of risking whole-page extraction failure.
- Query-scoped extraction (
?query=).GET /apiand the MCPread_urltool accept an optionalqueryparam that returns only the sections of the page relevant to that text - a BM25 ranking over heading-based sections of the converted markdown (paragraph-level fallback for pages with fewer than two headings), with no network calls and no LLM involved. Budget viamax_tokens(default600, range64-20000). Falls back to the whole page (confidence: low) when nothing in the page scores against the query, or when the page is already small enough that extraction wouldn't help. New response headers (X-Extracted,X-Extract-Confidence,X-Extract-Sections,X-Extract-Original-Tokens,X-Extract-Returned-Tokens), aformat=jsonextractobject, and?frontmatter=truefields (extracted,extract_confidence,sections_selected,original_tokens,returned_tokens) expose the result. Extraction runs on the already-cached full page, so multiplequeryvalues against one URL cost a single fetch within the normal cache TTL. Omittingquery(or passing an empty/whitespace-only value) leaves/apioutput byte-identical to pre-3.4 behavior.
- Block Server-Side Request Forgery (SSRF) (closes #41). The URL-fetch endpoints (
GET /api, the MCPread_urltool, and the Reddit/web/Playwright fetch paths behind them) now resolve the target host and reject any resolved address in the private, loopback, link-local, CGNAT or cloud-metadata ranges - including169.254.169.254and100.100.100.200, the addresses used by the reported exploit. Each redirect hop is re-checked before it is followed, and the Playwright sidecar path is validated Node-side before dispatch. Requests to a blocked host return HTTP 403. - Self-hosters who intentionally need an internal host (e.g. an intranet wiki) can allowlist specific CIDRs and/or exact hostnames via the new
PULLMD_ALLOWED_HOSTSenv var (comma-separated, empty by default = block all internal targets). See.env.example. - Known residual: the guard checks resolved IPs at request time but does not pin the outbound socket to the checked address, so a DNS-rebinding attack (where the name resolves differently between the check and the actual connection) is not fully closed by this fix. When running behind an outbound HTTP proxy, the proxy performs its own name resolution, so its egress filtering - not this guard - is the authoritative layer for traffic it forwards.
X-Transcript-Statusresponse header (#37, closes #36). YouTube conversions now expose the transcript state (ok/none/blocked/error) as a response header, so programmatic consumers can tell a transient block from a genuinely absent transcript without parsing the body.- ScienceDaily lead-image recipe. A shipped site recipe for
*.sciencedaily.com/releases/**unwraps the article's#textcontainer so the lead image survives extraction. Without it, Readability picks the paragraph-dense inner block as the article and drops the sibling<figure>that holds the real lead image. General sites should still rely on theimagefrontmatter field (fromog:image) for a robust lead-image source.
- Cache hits now serve complete metadata. The cached-response path rebuilt a minimal
{title, url, quality}object, so on a cache hit the frontmatter lostimage(og:image/twitter:image),description,author,language, andsite, andformat=jsonreturnedmetadata: null. The full metadata persisted at extraction time (in the existingmetadatacolumn) is now served on cache hits in both the frontmatter and theformat=jsonresponse - no schema change needed. Consumers readingmetadata.ogImage/metadata.twitterImage(e.g. for an article lead image) now get it on cached pages too. - Tracking pixels no longer leak into the Markdown. 1x1 invisible beacon images (e.g. republish/analytics counters embedded inline in article text) were kept by Readability and rendered as bogus Markdown images.
cleanDomnow drops any<img>whose declared width and height are both ≤ 1; genuine wide/tall banners (only one tiny dimension) are spared. - YouTube: transient 429 distinguished from a missing transcript (#34, closes #33). A per-IP 429 on the timedtext endpoint was mislabeled as a permanent block; it is now classified honestly and not cached, so a later retry can pick up the now-available transcript.
- Sidecar: bundle
yt_transcript.pyin the markitdown image (#35). The YouTube transcript helper was missing from the built image; it is now copied explicitly.
- Dedicated Hacker News pipeline. Hacker News URLs - item pages, individual comment permalinks, and listings (
/,/news,/newest,/ask,/show,/jobs,/best) - are now extracted through a purpose-built converter (via the HN API) instead of the generic HTML path. The result is a clean nested comment tree with per-comment permalinks and a## Comments (N of M)heading, replacing the layout-table soup the old path produced. Comment depth honors the existingcomment_depthcontrol, and extraction failures fall back to the generic web pipeline. Newsource: hackernewsvalue and an HN-orange source badge in the PWA. - PWA: share the converted Markdown (Web Share API). A share button next to Copy hands the current Markdown - exactly what is shown and copied, including the frontmatter block when the toggle is on - to the native OS/app share sheet. It appears only where the browser supports Web Share (e.g. mobile, Safari); on a non-cancel failure it falls back to copying so the content is never lost.
- PWA: the Frontmatter toggle updates the output instantly. Flipping the Frontmatter switch now adds or removes the YAML block immediately, with no second Pull. The Copy and Share buttons and the character count follow the toggle.
3.0.0 - 2026-06-10
- Clean markdown body by default. The inline source-attribution line (
**domain** · fetched+ url, or**filename** · fetchedfor local files) is no longer emitted in the response body. The same applies to Reddit posts: the inline meta line (**r/sub** · u/user · N ↑ · age · date+ url) is gone from the body; subreddit, author, upvotes, and publish date move into the frontmatter (subreddit,author,upvotes,published). The body now starts with# Titleand goes straight into content. The source URL, fetch date, and all extraction metadata are unaffected - they remain in the YAML frontmatter as before. SetPULLMD_SOURCE_HEADER=trueto restore the legacy inline header verbatim. Self-hosters upgrading from v2.x should reviewMIGRATION.md.
PULLMD_FRONTMATTER_FIELDSallowlist. Comma-separated list of frontmatter field names to include in the YAML block (e.g.title,url,source,llm_tokens). Unset - all fields are emitted (backward-compatible). Unknown names are silently ignored with a one-time startup warning; if every listed name is unknown, the allowlist is ignored and all fields are emitted as a safe fallback.- Document conversion via the MarkItDown sidecar (
MARKITDOWN_URL). NewPOST /api/fileendpoint accepts raw document bytes (25 MB cap) for PDF, DOCX, PPTX, XLSX, EPUB, ZIP, CSV, JSON, XML, and more. Non-HTML URLs detected as documents are also routed through the sidecar automatically inextractWeb. IfMARKITDOWN_URLis unset, the document path is disabled and/api/filereturns502. These features were developed incrementally as versions 2.7.0-2.10.0 on the release branch but are first officially released here in 3.0.0. - Opt-in media tier (
PULLMD_VISION_*/PULLMD_STT_*). Image captioning and audio transcription (Whisper STT) run inside pullmd itself - no markitdown container needed. Per-modality or shared OpenAI-compatible credentials; each modality is enabled when its key is set. The PWA accepts image and audio uploads when the tier is enabled. Off by default; cloud backends cost per call and send content off-host - point*_BASE_URLat a local server to keep everything on-host. - Keyless YouTube transcripts (
MARKITDOWN_YOUTUBE=true). Routes YouTube URLs through the sidecar for title + description + full transcript. No API key required. Configurable timecodes (yt_timecodes:links/plain/none), block chunking (yt_chunk), preferred languages, and optional proxy. All options are also overridable per-request via query params on/apiand the MCPread_urltool. - Opt-in high-quality PDF tier (
PULLMD_PDF_OCR_API_KEY/PULLMD_PDF_OCR_BASE_URL/PULLMD_PDF_OCR_MODEL). Route PDFs through a vendor-neutral OCR provider that preserves tables - reference provider is Mistral OCR (mistral-ocr-latest, ~$0.002/page). Triggered per request with?pdf=ocr(on/api,/api/stream,/api/file, and the MCPread_urltool viapdf_ocr) or a recipefetch.pdf: ocrdefault. Default PDF handling is unchanged (free markitdown path). OCR failures fall back to markitdown automatically. Newsource: pdf-ocrvalue andpdf_pagesfrontmatter field. - LLM-usage and media metadata in frontmatter. When media or LLM features run, the response frontmatter carries
llm_model,llm_tokens,llm_prompt_tokens,llm_completion_tokens,audio_seconds,image_size, and (for YouTube)durationandviews. Media and channel metadata is emitted frontmatter-only - consistent with the v3 clean-body direction.
- Claude Code skill bundle renamed
web-reader→pullmd. Now served atGET /pullmd.zip; the old/web-reader.zipURL responds with a permanent redirect. Existing installs are not replaced by the new zip - remove the old skill first (rm -rf ~/.claude/skills/web-reader) or both will be active side by side. SeeMIGRATION.md. - The MCP
read_urltool description and the skill instructions now cover the v3 capabilities (documents, YouTube transcripts, media captioning/transcription, PDF OCR), and the MCP server reports the real package version. - Media conversion results carry per-modality
sourcelabels (image-caption/audio-transcript) instead of a genericmarkitdown.
- Relative image and link URLs are now resolved against the source page before extraction. Previously, root-relative paths (e.g.
/images/photo.webp) survived into the markdown verbatim and rendered as broken images on share pages. - Document conversions in the markitdown sidecar run in a sandboxed child process with a wall-clock timeout and optional memory cap, so a pathological file can't pin the sidecar (DoS hardening).
- Media frontmatter (image size, LLM usage, YouTube meta) survives the cache - cached responses now carry the same fields as the first request.
2.6.0 - 2026-06-08
- Convert local HTML files (#28). New
POST /api/htmlendpoint accepts a raw HTML body (max 10 MB) and runs it through the existing Readability + Trafilatura extraction pipeline —curl --data-binary @page.html -H 'Content-Type: text/html' …/api/html. Optionalurl=re-enables site recipes and the linked header; the file name can be passed via?filename=or theX-Filenameheader (URI-encoded; keeps names out of access logs);data:-URI images (SingleFile exports) are replaced by their alt text. Privacy by design: local files are never cached — no history entry, no share link, and telemetry logs a constant placeholder instead of the file name. Playwright is deliberately unavailable for uploaded HTML (user-supplied markup must never run in a server-side browser). - PWA: convert a local
.htmlfile - drag-and-drop it onto the page (desktop), or click/tap the dashed hint below the URL field to pick a file. The file picker works on desktop and mobile, so a downloaded page can be converted on a phone too. Friendly errors for non-HTML files, oversized files (413), and JavaScript app shells (422).
2.5.0 - 2026-06-06
- Sessions no longer die a hard 7 days after login regardless of activity (#26). The session cookie is now re-issued with a fresh
Max-Agewhenever the DB-side sliding expiry bumps (existing once-per-minute throttle), so browser cookie and DB session finally slide together.
- Session TTL raised from 7 to 90 days (sliding). Anyone active at least once every 90 days stays logged in.
- Expired session in the PWA now redirects to the login page instead of showing a bare "Authentication required" error; the requested URL is carried through
/login?next=and the conversion resumes automatically after login (#26). GET /shareis now auth-gated. Share intents from a logged-out device go straight to the login page and return to the shared URL after login — no more lost share-target URLs. Instances withPULLMD_AUTH_MODE=disabledare unaffected (#26).
2.4.1 - 2026-05-13
- Permalink bar hidden by ad-blockers (e.g. uBlock Origin). Renamed all
share-bar/share-url/share-copy-btnCSS classes and IDs topermalink-bar/permalink-url/permalink-copy-btnso cosmetic filter lists no longer suppress the element. - Service Worker: removed the aggressive forced tab-reload on SW activation that had been introduced as a debugging artifact.
:latestDocker tag now tracks the most recent release again. The v1 → v2 migration grace period is over; self-hosters who want to pin v1 should use:1or:1.2.
2.4.0 - 2026-05-11
- Rendered Markdown view in the PWA (closes #23, thanks @sladg). New
Raw | Renderedsegmented toggle in the result header lets users see the fetched output as actual formatted HTML (headings, lists, links, images, tables, blockquotes, code blocks) instead of the raw source. Raw remains the default; the chosen mode is persisted inlocalStorage(pullmd-view-mode).- GFM rendering via self-hosted marked v12.0.2 (~30 KB).
- HTML sanitization via self-hosted DOMPurify v3.4.2 (~20 KB). Strips scripts, inline styles, event handlers,
javascript:URLs. - Rendered links open in a new tab with
rel="noopener noreferrer". - Lazy first-render: the rendered DOM is only built when the user first switches to Rendered for a given result, so users who only ever copy raw Markdown pay no rendering cost.
- Copy button still copies the raw Markdown source regardless of the active view.
- Both themes (dark + paper) styled via existing CSS variables — no new tokens.
- Service Worker precaches the new vendor files (
vendor/marked.min.js,vendor/purify.min.js) so the rendered view also works offline in the installed PWA.CACHE_NAMEbumped topullmd-v20.
- Out of scope for v2.4: syntax highlighting, math/diagrams, Reddit-style spoiler syntax, side-by-side view, rendered view for
/s/:idshare links (still puretext/markdown).
2.3.0 - 2026-05-11
- OAuth 2.1 Authorization Code flow with PKCE-S256 for the claude.ai web custom connector, Claude Desktop's custom-connector dialog, and other MCP-spec-compliant clients (closes #6, #10 — thanks @WinFuture23 for raising the Claude Desktop auth gap).
- Dynamic Client Registration (
POST /oauth/register, RFC 7591). - Authorization endpoint (
GET /oauth/authorize) with server-rendered consent screen (DE/EN). - Token endpoint (
POST /oauth/token) withauthorization_codeandrefresh_tokengrants. Refresh tokens are rotated on every refresh; reuse triggers chain-wide invalidation. - Revocation endpoint (
POST /oauth/revoke, RFC 7009). - Discovery:
/.well-known/oauth-authorization-server(RFC 8414) and/.well-known/oauth-protected-resource(RFC 9728). - Access tokens are HS256 JWTs (
typ: at+jwt, RFC 9068), audience-bound to<PUBLIC_URL>/mcp, 1h TTL. Refresh tokens are opaque, sha256-hashed in storage, 30d TTL. - Hardcoded redirect-URI allowlist:
https://claude.ai/api/mcp/auth_callbackandhttps://claude.com/api/mcp/auth_callback. WWW-Authenticate401 responses include theresource_metadataparameter pointing at the protected-resource metadata document.
- Dynamic Client Registration (
- Rate limiting on
/oauth/tokenand/oauth/authorize(60 req/min/IP) and/oauth/register(10 req/h/IP). - CORS on
/oauth/token,/oauth/register,/oauth/revoke,/.well-known/*, and/mcp(wildcard origin, no credentials — Bearer header travels independently).
lib/auth.jsmiddleware accepts a third bearer-token type (OAuth JWT) via an injected verifier. Sessions and API keys (pmd_*) continue working unchanged.
- New env var
OAUTH_JWT_SECRETenables OAuth. Must be 32+ chars. Generate viaopenssl rand -hex 32. PUBLIC_URLis required when OAuth is enabled (used as JWTiss/audand in discovery metadata).
- New tables
oauth_clients,oauth_auth_codes,oauth_refresh_tokensare created automatically on first boot. No manual SQL. - OAuth is opt-in — without
OAUTH_JWT_SECRET, behavior is unchanged from v2.2.x. SeeMIGRATION.mdfor the full upgrade path.
2.2.0 - 2026-05-06
- Site Recipe Engine (closes #18). Declarative
site-recipes.jsonfor per-host preprocess, fetch, select, and extractor rules. Default recipes ship in the repo (site-recipes.default.json); self-hosters can mountdata/site-recipes.jsonor setPULLMD_SITE_RECIPESto point elsewhere. Four recipe categories:preprocess— DOM cleanup actions (remove-attr,remove-class,remove-element,unwrap) applied before extractionfetch— render forcing (render: force|skip), wait-for selector, mobile UAselect— extra remove-selectors added tocleanDomextractor— preferred extractor per host (readability,trafilatura,playwright)
- Public endpoint
GET /api/recipes/status(no auth) — counts loaded/rejected recipes per source for monitoring. - Cache invalidation on recipe change. When recipe content changes between server boots, all cache rows become stale and re-extract on next access (lazy, on-demand).
- Playwright sidecar accepts new optional fields:
waitFor(CSS selector),waitTimeoutMs(capped at 15 000),mobileUa(boolean). Backwards compatible — old fields are silently passed through. - Initial default recipes covering Future PLC sites (paywall + recommendation widgets — seeded by @WinFuture23's analysis in #17) and GitHub Issues (JS-rendered comments).
- Playwright sidecar bundles
playwright-stealthto mitigatenavigator.webdriver-style headless detection on JS-driven anti-bot pages.
- Sites behind cookie-based consent walls (third-party CMP frameworks like TCF v2) are not unlocked by recipes alone in this release. Such sites redirect non-consenting visitors to a JS-rendered consent UI and only return article content once HttpOnly cookies are set after a click. Write a custom recipe with whatever combination of
select.remove,extractor, andfetchsettings works for your specific source.
2.1.0 - 2026-05-05
- PWA: persist frontmatter toggle, comments toggle and comment depth across reloads via
localStorage(keyspullmd-frontmatter,pullmd-comments,pullmd-comment-depth). Closes #20. - PWA: scroll to top when selecting a history/archive entry, so the new content is visible after click. Closes #19.
2.0.0 - 2026-05-02
Breaking: PullMD now supports an authentication system. Existing installs keep working unchanged (default
PULLMD_AUTH_MODE=disabled); operators who want auth must followMIGRATION.md.
Pulling v2.x: Through 2026-05-13,
:latestcontinued pointing at v1.x. Since v2.4.1 the:latesttag tracks v2.x again; self-hosters who want to stay on v1 should pin:1or:1.2.
- Three auth modes (
disabled/single-admin/multi-user) controlled byPULLMD_AUTH_MODE. - Web sessions:
POST /login,POST /logout,GET /signup,GET /api/me, server-rendered HTML for/loginand/signup. - Per-user API keys:
pmd_<32-char-base62>format, sent asAuthorization: Bearer pmd_xxx. Manage at/settings. Stored as SHA-256 hashes. - Per-user history:
/api/historyand/api/archiveare scoped toreq.userwhen authenticated. - Admin CLI:
node scripts/admin.js {list-users,reset-password,make-admin}. - Schema:
users,sessions,api_keys,user_fetchestables, plusconversions.user_id. - Argon2id password hashing.
/api,/api/stream,/mcp,/api/history,/api/archive,/api/cache/:id,DELETE /api/cacherequire auth when mode !=disabled./s/:idshare links remain public in all modes (design choice)./api/confignow exposesauthMode.
PULLMD_AUTH_TOKEN(legacy bearer compat) — works only insingle-adminmode, removed in v3.0.
- Internal v2.0 security review (
REVIEW-FINDINGS.mdin the v2.0 release commit) tightenedDELETE /api/cache*to admin-only in non-disabledmodes and stopped flashing new API keys through URLs.
See MIGRATION.md.
1.2.0 - 2026-05-02
- User-Agent rotation from a real-world UA pool with static seed fallback (closes #14, thanks @WinFuture23). Sites that silently degraded responses to the previous hardcoded Chrome 131 UA now extract cleanly.
- Charset detection from HTML meta tags when
Content-Typelacks acharsetdirective (closes #8, thanks @WinFuture23). Eliminates mojibake on legacy ISO-8859-1 pages (e.g.winfuture.de). - Share URL embedded in MCP frontmatter responses so LLMs stop hallucinating
pullmd.comas the host instead of the operator's actual instance (closes #1, #9, thanks @looselyhuman and @WinFuture23). - HTML preprocessing to recover dropped paragraphs adjacent to inline recommendation widgets, plus per-host extractor override (closes #17, thanks @WinFuture23 — analysis that later seeded the v2.2 recipe engine).
- GHCR mirror for all three images alongside Docker Hub (closes #13, thanks @Kampe).
DISABLE_PUBLIC_HISTORYenv var to hide the history/archive views on shared instances (closes #7).
- Reddit threads with embedded images: post selftext now renders before the media block, so the header is visible alongside the image and the comment tree is no longer truncated (closes #12).
- Docker volume mount path:
/app/datacorrected to/dataso the documented bind mount actually works (closes #15, PR #16 — thanks @andrewthetechie).
1.1.3 - 2026-04-29
- MCP response shape:
read_url,get_share,list_recentno longer returnstructuredContentalongsidecontent[0].text. Claude Code and other MCP clients were surfacing the metadata JSON instead of the actual Markdown (closes #1, originally reported by @looselyhuman; upstream Anthropic bug tracked atanthropics/claude-code#54450). - Reddit images: image renders before selftext to match Reddit's visual order, so the header/title isn't suppressed by the image preview.
- CI matrix corrected so all three images (pullmd, pullmd-trafilatura, pullmd-playwright) properly publish to Docker Hub at
:1.1.3/:1.1/:1/:latest, alongside GHCR.
1.1.2 - 2026-04-27
- Delete button in the recents list on the homepage. Each row gets a discreet
×on hover — same one-click pattern as the archive view.opacity: 0by default, fades in on row hover.
cache.history()now includes the rowidso the PWA can issueDELETE /api/cache/:idstraight from the recents list.- Service Worker cache bumped v17 → v18.
1.1.1 - 2026-04-27
- Readonly
<input>values surface as<code>. Click-to-copy widgets on CMS-driven sites (API model slugs, embed snippets, share links) used to hide their content in<input type="text" readonly value="…">, whichnode-html-markdownsilently dropped as form chrome. They now come through as inline code. Onlyreadonlytext inputs are transformed; real forms are untouched. - Bare-UUID alt-text on images is dropped. Strapi/Directus/Hygraph etc. leak asset IDs as the
altattribute. A strict UUID-v4 regex on the alt — drop the alt only, keep the image link. Descriptive alt text is preserved.
- Diffed against 9 representative URLs (paulgraham.com, hillelwayne.com, 12factor.net, joelonsoftware.com, grugbrain.dev, RFC 2324, Wikipedia, HN frontpage) → byte-identical output. mistral.ai/pricing → expected diff (model slugs now
code, UUID alts dropped).
1.1.0 - 2026-04-27
- Playwright fallback for JavaScript-rendered pages. When Readability + Trafilatura returns body-soup or low-quality output, the pipeline retries via headless Chromium and re-extracts on the rendered DOM. Source label is
playwright; predicate reason is preserved inmetadata.extractorReason. - New Python sidecar
pullmd-playwright(FastAPI + Chromium,asyncio.Semaphoreconcurrency limit, 20 s hard timeout, soft-fail networkidle wait). Optional — leavePLAYWRIGHT_URLunset and the pipeline silently degrades to static extraction. - Live status events for the PWA via Server-Sent Events. New
GET /api/streamendpoint emitsfetching → extracting → (rendering) → result. Other clients (curl, Claude, MCP) continue to use/apiunchanged. lib/render-decision.js— predicate over (i) readability fellback + thin, (ii) body-soup signature heading/paragraph ratio, (iii) low-quality safety net.?render=forceand?render=skipmanual overrides on both/apiand/api/stream.- Frontend status line under the spinner with i18n (DE/EN); EventSource auto-falls-back to plain fetch on transport failure.
client_modequery-param fallback so PWA-originated SSE requests keep their attribution.- Cache-ordering fix from the unreleased v1.0.3 included here: re-fetched URLs bubble to the top of the recents list.
- The Playwright sidecar image is ~3.7 GB on disk (pulls Chromium on first build).
/api/streamand?render=forceare not rate-limited — recommended to keep PullMD behind an authenticating reverse proxy when exposed to the public internet.
1.0.2 - 2026-04-27
First public release. Self-hosted URL → Markdown service for humans and AI agents — PWA, REST, MCP, and a downloadable Claude Code skill, all from one container.
- Multi-strategy extraction. Cloudflare's native Markdown endpoint where available; Readability + Trafilatura running in parallel, picked by quality score elsewhere.
- Reddit-aware path. Auto-detects threads (incl.
redd.itand/s/share links), returns post + nested comment tree with tunablecomment_depth/comment_limit. - Refreshable share IDs. Every conversion gets an 8-hex permalink;
GET /s/:idre-fetches if older than 1h, falls back to the last good snapshot if the source dies. - Four interfaces, one codebase. PWA frontend, REST (
/api), MCP (POST /mcp, Streamable-HTTP), downloadable Claude Code skill bundle. - Multi-arch images (
linux/amd64,linux/arm64) on Docker Hub.
- First-boot DB file error on Docker volume mounts (thanks @goran-zdjelar, #2).