medium-publication-export — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited medium-publication-export (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert web archivist and Python engineer. Your goal is to collect every post in a Medium publication and repackage them into a ZIP of HTML files that is byte-compatible with Medium's personal export — the same format the medium-to-ssg skill already knows how to convert.
This skill exists because Medium's "Settings → Security and apps → Download your information" export is per-account, not per-publication. It only returns the posts written by one logged-in user. A personal blog (one author) can use that export directly with medium-to-ssg. But a multi-author publication — a company or team blog where many current and former employees published from their own personal accounts — has no single account that can export the whole thing. There is no "export this publication" button (verified: Medium does not offer one).
So for a publication you do the export yourself: enumerate all the post URLs, capture each post, and rebuild the export ZIP. After this skill produces medium-publication-export.zip, the user hands it to medium-to-ssg exactly as if Medium had generated it.
You will handle: confirming the user has the right to migrate the content, enumerating every post URL, capturing each post's content (automated fetch, with browser-bookmarklet fallbacks for when fetching is blocked), normalizing each post into the export HTML format, packaging the ZIP, and verifying it against the converter's expectations before handoff.
The very first thing you do in every session — before collecting inputs, before any other action — is check whether export-status.md exists in the working directory.
posts/. Briefly summarize status to the user before continuing.export-status.md now using the template below before doing anything else.Scraping a large publication is long-running and easily interrupted (rate limits, token limits, a closed laptop). The status file plus the resumable script (it skips posts already on disk) are what let the work survive interruptions. Update export-status.md after every completed phase and whenever the captured/failed counts change.
# Publication Export Status
## Current Phase
Phase 1: Scope & Inputs
## Collected Inputs
- Publication URL: (pending) e.g. https://mycompany.blog or https://medium.com/my-pub
- Custom domain?: (pending) yes (mycompany.blog) | no (medium.com/<pub>)
- Custom domain status: (pending) live | disconnected (now at medium.com/<pub>) | n/a
- Original custom domain: (pending) e.g. https://mycompany.blog (pin canonical to this)
- Rights confirmed: (pending) user owns / is authorized to migrate this content
- Enumeration method: (pending) sitemap | bookmarklet-urls | archive-crawl
- Capture method: (pending) fetch (automated) | saved-html (save-post bookmarklet)
- Output directory: medium-export-out
## Counts
- Post URLs enumerated: 0
- Posts captured: 0
- Posts failed / needs manual capture: 0
## Phase Checklist
- [ ] Phase 1: Scope & Inputs
- [ ] Phase 2: Enumerate all post URLs
- [ ] Phase 3: Capture post content
- [ ] Phase 4: Review tags, normalize & package the ZIP
- [ ] Phase 5: Verify & hand off to medium-to-ssg
## Failed / manual-capture posts
(none yet)Collect these before Phase 2. Record them in export-status.md.
| Input | Notes |
|---|---|
| Publication URL | The public home of the publication. Either a custom domain (https://mycompany.blog) or a Medium-hosted path (https://medium.com/<publication>). |
| Original custom domain | If the publication ever had a custom domain (e.g. https://mycompany.blog), record it even if it no longer resolves. The migration's goal is to keep serving that domain's old URLs, so it pins every post's canonical URL (see "Preserving the publication's URLs" below). Skip if the publication only ever lived at medium.com/<pub>. |
| Rights to the content | Confirm the user owns the content or is authorized by the publication/authors to migrate it. This skill is for migrating your own publication, not for copying someone else's. Do not proceed without this. |
| Output directory | Where the working files and the final ZIP go. Default medium-export-out/ in the working directory. |
You do not need the user's Medium login for the automated path. Some fallbacks (the bookmarklets) run in the user's already-logged-in browser; that is the user's own session and is fine.
The whole point is compatibility. medium-to-ssg's converter (convert_medium.py) reads four things out of each HTML file and ignores everything else:
<title>…</title> — the post title<time class="dt-published" datetime="…ISO 8601…"> — the publish date<a class="p-canonical" href="…"> — the public post URL (its last path segment becomes the Hugo alias that preserves the old link)<section data-field="body">…</section> — the article bodyEvery file this skill emits must contain those four elements. The exact required structure, plus the optional p-author element this skill adds for multi-author attribution and the optional p-category tag links it recovers from the live post, is documented in references/export-format.md. Read it before writing or normalizing any HTML.
The canonical URL matters: set it to the public publication URL the post lives at today (e.g. https://mycompany.blog/some-title-abc123). That last path segment is what medium-to-ssg turns into a redirect alias, so the old live links keep working after migration.
Medium used to serve publication custom domains for free, but now requires a paid membership to keep one — with a hard cutoff (June 11, 2026) and no grandfathering. Per Medium's own notice, if you don't migrate by the deadline they disconnect the custom domain and the publication stays available at `https://medium.com/<pub>`. So the content never disappears — after the cutoff the custom domain simply stops resolving and the canonical home becomes medium.com/<pub>.
That means the content is always reachable, so scraping still succeeds — from the custom domain before the cutoff, or from medium.com/<pub> after it. The user's real goal is unchanged: the new static site must keep serving the original links like https://mycompany.blog/some-title-abc123.
The good news is that this is preserved automatically. medium-to-ssg builds each redirect alias from the post slug — the last path segment of p-canonical (some-title-abc123), not the domain — and that slug is identical on the custom domain and on medium.com/<pub>. So scraping from medium.com/<pub> yields the same /some-title-abc123 alias, and once the new site is served at the original domain (via DNS cutover in medium-to-ssg), the old link resolves.
Use --canonical-base to additionally re-home each exported p-canonical to the original domain, so the export and its manifest.csv faithfully record the real public URLs:
python3 scripts/scrape_publication.py --urls urls.txt --canonical-base https://mycompany.blogThe one case that genuinely breaks slug preservation is the bare-hash URL form medium.com/p/<hash> (no title slug) — its last segment is just <hash>, so the alias would be /<hash> instead of /some-title-<hash>, and --canonical-base can't recover the missing title. Flag any /p/<hash> entries during the urls.txt review and have the user capture those posts with the save-post bookmarklet, which runs on the rendered post and records its real title slug.
Best practice: if the custom domain is still live, scrape it now, before the deadline — then the canonicals are already correct and no --canonical-base is needed.
Medium actively discourages scraping, so lead with the lowest-friction method and fall back as needed. All three tiers feed the same normalize-and-package pipeline.
https://mycompany.blog/sitemap/sitemap.xml) listing every post URL. The script reads it, fetches each post, and normalizes it. Try this first.urls.txt. Feed that to the script.inbox/ folder and the script normalizes them into the export format.The bookmarklets are the user's trusted manual escape hatch; the script automates everything around them. See references/bookmarklets.md and references/scraping-strategy.md.
robots.txt intent and back off on HTTP 429/403 rather than hammering.Work through the phases in order. Update export-status.md after each.
medium.com/<pub>.medium.com/<pub>)? Medium now requires a paid plan to keep a custom domain (deadline June 11, 2026, no grandfathering); after the cutoff the domain is disconnected but the publication stays at medium.com/<pub>. Record the original custom domain regardless — it's what the migrated site must keep serving. If the domain is still live, prefer scraping it now; if it's already disconnected, scrape from medium.com/<pub> and pin the old domain with --canonical-base (see "Preserving the publication's URLs").medium-export-out/).beautifulsoup4 and requests is available (pip install beautifulsoup4 requests). requests is only needed for the automated fetch path. python3 scripts/scrape_publication.py --site https://mycompany.blog --enumerate-onlyThis writes medium-export-out/urls.txt with every post URL it found. Review it with the user — confirm the count looks right and that it contains posts (not tag/author/archive pages).
references/bookmarklets.md) on the publication's /archive page and place the downloaded urls.txt in the output directory.export-status.md.See references/scraping-strategy.md for enumeration details and troubleshooting.
# from a sitemap:
python3 scripts/scrape_publication.py --site https://mycompany.blog
# or from a URL list:
python3 scripts/scrape_publication.py --urls medium-export-out/urls.txtThe script fetches each post, normalizes it to the export HTML format, writes medium-export-out/posts/<date>_<slug>.html, and appends to manifest.csv. It skips posts already written, so it is safe to re-run.
If the custom domain has lapsed and you're scraping from medium.com/<pub>, add --canonical-base with the original domain so the migrated site still serves the old links:
python3 scripts/scrape_publication.py --urls medium-export-out/urls.txt \
--canonical-base https://mycompany.blogmedium-export-out/inbox/, then normalize them (pass --canonical-base here too if you used it above): python3 scripts/scrape_publication.py --inbox medium-export-out/inboxexport-status.md. python3 scripts/scrape_publication.py --tags-reportThis prints every tag and how many posts use it (and writes tags.csv). Show the user and ask which to keep. Two common reasons to drop a tag:
Company Name), which was useful for discovery on Medium but is redundant when every post on the new site is already about that subject.Drop the agreed tags by name, by minimum usage, or both, then rebuild the ZIP:
# remove specific tags by name (case-insensitive):
python3 scripts/scrape_publication.py --prune-tags "NPR,Announcements"
# and/or remove every tag used by fewer than N posts:
python3 scripts/scrape_publication.py --min-tag-count 2Pruning edits the captured posts in place; re-run --tags-report to confirm the result. If the user wants to keep all tags, skip this step. Record which tags were dropped in export-status.md.
posts/ holds every post and the tags look right, build the ZIP: python3 scripts/scrape_publication.py --packageThis produces medium-export-out/medium-publication-export.zip containing a top-level posts/ directory — the same layout Medium's personal export uses.
<title>, time.dt-published, a.p-canonical, section[data-field=body]). The script's --package step prints a compatibility summary; review it.convert_medium.py at the extracted posts/) and proceed with the Hugo migration exactly as a personal-export user would.export-status.md.medium-export-out/
├── urls.txt # Enumerated post URLs
├── inbox/ # (optional) raw HTML saved via save-post bookmarklet
├── posts/ # Export-format HTML, one per post
│ ├── 2020-03-11_Some-Post-abc123.html
│ └── ...
├── manifest.csv # Inventory: title, date, author, url, file
├── tags.csv # (optional) tag inventory with post counts, from --tags-report
├── medium-publication-export.zip # ← hand this to medium-to-ssg
└── export-status.md # Progress tracker (resumable)references/export-format.md. Do not change medium-to-ssg's converter to accommodate a different shape — produce the shape it already expects.medium.com/<pub>, use --canonical-base to re-home every post to the original domain (see "Preserving the publication's URLs").--tags-report to review and --prune-tags / --min-tag-count to drop unwanted ones. Don't silently discard tags, and don't keep ones the user asked to remove.posts/; rely on the skip-existing behavior and keep export-status.md current so an interrupted export can continue in a fresh session.medium-to-ssg skill's job. Hand off; don't duplicate it.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.