<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bozhidar Valchev</title>
    <description>The latest articles on DEV Community by Bozhidar Valchev (@bozhidar_valchev).</description>
    <link>https://dev.to/bozhidar_valchev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3591741%2F5c8daa0d-71cb-4ee9-b792-803755d72c13.jpg</url>
      <title>DEV Community: Bozhidar Valchev</title>
      <link>https://dev.to/bozhidar_valchev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bozhidar_valchev"/>
    <language>en</language>
    <item>
      <title>Villagers Remember: Quests and Trust for Minecraft Villagers</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Wed, 21 Oct 2026 10:54:40 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/villagers-remember-quests-and-trust-for-minecraft-villagers-server-side-1h4f</link>
      <guid>https://dev.to/bozhidar_valchev/villagers-remember-quests-and-trust-for-minecraft-villagers-server-side-1h4f</guid>
      <description>&lt;p&gt;Villagers Remember is a mod for Minecraft 26.3, for Fabric and NeoForge, in which Minecraft villagers ask you for help and remember who helped them. It runs on the server, so players join with a normal vanilla client and install nothing. The first public beta, 0.13.0, is on &lt;a href="https://www.curseforge.com/minecraft/mc-mods/villagers-remember-quests-trust-fair-trades" rel="noopener noreferrer"&gt;CurseForge&lt;/a&gt; and &lt;a href="https://github.com/valcheffnet/villagers-remember/releases" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. I publish it under my gaming name, SasnycoN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wanted it
&lt;/h2&gt;

&lt;p&gt;In vanilla Minecraft a villager is a shop with a face. You trade with it and walk away. I have spent hours in trading halls and I couldn’t tell you the name of a single villager in them, because they don’t have one.&lt;/p&gt;

&lt;p&gt;What I really wanted was something like Millénaire. I have been playing it since 2021, and my best friends in that world are the Byzantine traders of Istron, but Millénaire needs every player to install it. On our server my friends have one rule, the same one that gave me my other mod, Less Villager Chatter: nobody installs anything. So it had to use vanilla villagers and blocks and run entirely on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every villager is someone
&lt;/h2&gt;

&lt;p&gt;Right-click a villager and they greet you based on what you’ve done for them. A stranger says “Hm. What do you want?” A villager you have helped for weeks says “You! The whole village talks about you.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fanmo3r7w3wr63q9iixix.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fanmo3r7w3wr63q9iixix.jpg" alt="Two greetings from Minecraft villagers side by side: Boris the weaponsmith, a best friend, with 16% off, and Vela the librarian, who has never met you" width="800" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each villager keeps their own memory of you, so Stoyan the farmer can be your best friend while the butcher next door has never heard of you. They all have names now, mostly old Bulgarian ones (in English Мария simply becomes Maria), and every village gets one of 230 names, many of them real Bulgarian villages.&lt;/p&gt;

&lt;p&gt;Trust takes time. In a new village only one villager dares to talk to you, the one with the “!” (duh) over their head, and every time you help someone, one more opens up. Trading works too: keep trading with a villager until they reach a new level and they warm up to you. The ones you have traded with a lot already know you, which means the trading hall you have used for ages opens up at once.&lt;/p&gt;

&lt;p&gt;To move up a tier with a villager, points aren’t enough. They give you a trial of trust first, a harder errand that pays double. Pass it, and they hand you the next set of errands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errands that pay fairly
&lt;/h2&gt;

&lt;p&gt;Every villager with a job has a few errands a day. There are 21 kinds of tasks: craft, smelt, mine, harvest, fish, breed, kill, brew, enchant, find a structure and so on. The farmer wants twenty wheat, while the cartographer sends you to find a shipwreck.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2tn59ii7yuhx7vt7z5md.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2tn59ii7yuhx7vt7z5md.jpg" alt="Kalcho the armorer wants two iron chestplates and gives one back enchanted" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I spent most of the balancing time on one question: is this worth doing? An errand always pays more than the same goods would bring from a villager, so it never makes sense to just trade instead. A first errand takes about five minutes for someone with no farms. A top one takes half an hour and can’t be pulled out of a chest, like an elder guardian or two zombie villagers to cure.&lt;/p&gt;

&lt;p&gt;Errands pay experience and standing. Emerald rewards are off by default, because emeralds are the currency of trading and I didn’t want to flood the server with them. A server that wants them turns them on with one command.&lt;/p&gt;

&lt;p&gt;Your standing also sets your prices, right in the normal trading screen: up to 40% off with a best friend in a village where you are the Hero, and up to 50% more if you hurt the village.&lt;/p&gt;

&lt;h2&gt;
  
  
  Something for the whole server
&lt;/h2&gt;

&lt;p&gt;The biggest jobs aren’t for one player. Once a game month every village posts an order for everyone, sized by how many people play regularly: 512 food before winter for each of them, or 256 logs to fix the roofs after a storm. Anyone brings any amount, and when it’s done everyone is paid by their share.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim8a4z4t6zii9443jm66.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim8a4z4t6zii9443jm66.jpg" alt="A village order for 256 logs, brought by anyone on the server" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the long run there are weekly contracts, legendary items named after the villager who gave them (“Stoyan’s Hoe”), fame titles in chat and one patron per village. The patron is the player with the most standing there. Anyone who does more takes the place, and the whole server hears about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works with a vanilla client
&lt;/h2&gt;

&lt;p&gt;This was the most fun part to build. The client knows nothing about the mod, so everything you see has to be something vanilla already knows how to show.&lt;/p&gt;

&lt;p&gt;The menus are the dialog screens Minecraft added for servers, and each button in them just runs a command on the server. The “!” and “?” over villagers’ heads are text displays, the same entities map makers use for floating text, while the list of errands on the right is the old sidebar scoreboard. Even the language is decided on the server: the client reports which one it uses, and the mod answers in it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsels4f9lasaftkorid0g.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsels4f9lasaftkorid0g.jpg" alt="The village board at the bell: errands, residents, village order, treasury and your standing" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One trap cost me some time. Villager names live in language files, and my first version kept them there as lists. Vanilla’s server-side language loader can’t handle lists in those files, so the names now live in files of their own.&lt;/p&gt;

&lt;p&gt;The errands themselves are datapack JSON files, so a server can add its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;Take the file for your server’s loader:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fabric: &lt;code&gt;villagers-remember-0.13.0-beta.jar&lt;/code&gt;, with Fabric Loader 0.19 or newer and Fabric API&lt;/li&gt;
&lt;li&gt;NeoForge: &lt;code&gt;villagers-remember-neoforge-0.13.0-beta.jar&lt;/code&gt;, with NeoForge for 26.3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop it into the server’s &lt;code&gt;mods&lt;/code&gt; folder. In singleplayer it works the same when installed on the client. Players with the mod on their client also get a settings screen, where server admins can change the server’s settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  It’s a beta
&lt;/h2&gt;

&lt;p&gt;It runs well on my test worlds, but a mod this size always has corners nobody has walked into yet. Balance is the part I’m least sure about, because five minutes of work for one player can be an hour for another.&lt;/p&gt;

&lt;p&gt;The bigger plan comes after that. I’d like villages to grow the way they do in Millénaire, with villagers building new houses as the village prospers, and errands that send you to the next village over. That is still a long way off, but the mod already knows where each village ends and who lives in it, so it has somewhere to start.&lt;/p&gt;

&lt;p&gt;If something breaks or feels off, come to the &lt;code&gt;#villagers-remember&lt;/code&gt; channel on the &lt;a href="https://discord.gg/X6yxJpk9Fz" rel="noopener noreferrer"&gt;ValcheffNet Discord&lt;/a&gt;. Write what happened in your own words and a person will answer. You don’t need a GitHub account for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks
&lt;/h2&gt;

&lt;p&gt;The villager heads in the screens come from Minecraft Heads, who kindly let me use them. Thank you.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/villagers-remember-minecraft-villager-quests/" rel="noopener noreferrer"&gt;Villagers Remember: Quests and Trust for Minecraft Villagers, Server-Side&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>minecraft</category>
      <category>gamedev</category>
      <category>java</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Scriptorium: Scraping Entire Documentation Sites and Document Archives to Markdown</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:02:01 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/scriptorium-scraping-entire-documentation-sites-and-document-archives-to-markdown-52b9</link>
      <guid>https://dev.to/bozhidar_valchev/scriptorium-scraping-entire-documentation-sites-and-document-archives-to-markdown-52b9</guid>
      <description>&lt;p&gt;Scriptorium saves a documentation website to your own disk as Markdown, one file per page. It’s a command-line tool packaged as a skill for Claude Code and Codex. The actual converting is done by Microsoft’s MarkItDown. Scriptorium adds the parts MarkItDown doesn’t try to do: it finds the pages through the sitemap, puts each one in a folder tree shaped like the site, repairs the links between them and strips the menus and footers. Then it tells you what it did, and the numbers in that report have to balance. It also converts local documents, PDF, Word, PowerPoint and Excel, and a whole ZIP archive of them in one go. It’s free under the MIT license: &lt;a href="https://github.com/valcheffnet/scriptorium" rel="noopener noreferrer"&gt;github.com/valcheffnet/scriptorium&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I work with monitoring products, and their documentation runs to thousands of pages. I wanted it on my own disk for two reasons. I can search all of it at once instead of one browser tab at a time. And I can give it to an AI assistant, which then answers from the documentation instead of from memory. A model’s memory of a product is usually a few versions old and wrong on exactly the details you’re asking about.&lt;/p&gt;

&lt;p&gt;The name comes from the room in a monastery where texts were copied by hand, one page at a time. That’s the whole design brief: an accurate copy of somebody else’s work, for your own reading.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkn282wsi548vd3ry2u6w.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkn282wsi548vd3ry2u6w.gif" alt="A medieval scribe copying a manuscript while an angel dictates" width="381" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;GIF: &lt;a href="https://giphy.com/gifs/gifitup-medieval-gifitup2021-manuscript-mmH8yHjk0P1sx1Dpoq" rel="noopener noreferrer"&gt;GIF IT UP&lt;/a&gt;, CC BY-SA&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I wrote it
&lt;/h2&gt;

&lt;p&gt;It started as a workflow I put together to copy the Dynatrace documentation, about 4,400 pages. It did the job, but it was written for that one site and couldn’t be pointed at any other. It also lost content without telling me, and I found out piece by piece over several weeks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What went wrong&lt;/th&gt;
&lt;th&gt;What it cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Filenames taken from the last part of the URL&lt;/td&gt;
&lt;td&gt;534 of 4,389 pages silently overwrote each other&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icons deleted based on image width&lt;/td&gt;
&lt;td&gt;1,453 table cells emptied on a single page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heading ids dropped during conversion&lt;/td&gt;
&lt;td&gt;46% of 8,992 links to a section pointed at nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Underscores in prose escaped by the converter&lt;/td&gt;
&lt;td&gt;57,068 identifiers written as &lt;code&gt;CUSTOM\_DEVICE&lt;/code&gt;, which a search for &lt;code&gt;CUSTOM_DEVICE&lt;/code&gt; will never find&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inline &lt;code&gt;data:&lt;/code&gt; images truncated&lt;/td&gt;
&lt;td&gt;every such image on a page reduced to the same 24-character string&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every page converted and no error appeared. Each problem turned up by accident, long after I had trusted the output and built on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fve7be8wvkq76v035063e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fve7be8wvkq76v035063e.gif" alt="This is fine" width="276" height="268"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Embedded from &lt;a href="https://giphy.com/gifs/VOq7iem25Z94Y" rel="noopener noreferrer"&gt;Giphy&lt;/a&gt;. “This is fine” by KC Green.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The underscore one is my favorite, because it isn’t a bug in anyone’s code. MarkItDown uses a library called markdownify, and markdownify escapes &lt;code&gt;_&lt;/code&gt; and &lt;code&gt;*&lt;/code&gt; in normal text by default, so they don’t turn into italics. In reference documentation most identifiers are written in plain text, without backticks. The page looks fine. The search returns nothing.&lt;/p&gt;

&lt;p&gt;A converter that drops a table still reports success. That’s the problem Scriptorium is built around.&lt;/p&gt;
&lt;h2&gt;
  
  
  A report that has to add up
&lt;/h2&gt;

&lt;p&gt;Every run prints a coverage equation, and it has to balance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;images = transcribed | skipped | queued | deleted
pages = converted | failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything deleted is listed with its URL and the reason. This rule came from a bad afternoon. An earlier version counted an image nobody had looked at the same way as one that was deliberately skipped. In one afternoon I got three different progress numbers for the same work, 762, 596 and 561. The real number was 497.&lt;/p&gt;

&lt;p&gt;So the tool keeps those two facts apart. “Nobody looked at this image” and “somebody looked and decided it was decoration” go in different columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Five stages. Only the fourth one needs a model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhfc623p772s32o0xpxh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhfc623p772s32o0xpxh.png" alt="Scriptorium's five stages, from a sitemap to Markdown on your disk, and the report whose numbers have to balance" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch and convert. It follows the sitemap and plans every output path before the first download, so a filename collision is caught in the plan and not discovered later as a missing page. Every file starts with the URL it came from.&lt;/li&gt;
&lt;li&gt;Inspect the result. It looks for broken characters and compares the output size with the page size. Three kilobytes of text from a 700 KB page usually means the site renders in the browser and you got the menu.&lt;/li&gt;
&lt;li&gt;Strip the site furniture. Navigation, cookie banners, breadcrumbs, footers and the little permalink icons next to headings.&lt;/li&gt;
&lt;li&gt;Turn images into text. A diagram becomes an ASCII drawing plus a Mermaid diagram, a screenshot of code or a config becomes a code block or a table, and decoration is skipped with a written reason. This is the stage that costs model calls, so deciding which images are worth it matters more than the transcription.&lt;/li&gt;
&lt;li&gt;Verify and report. It reads the files on disk instead of trusting what the earlier stages say they did.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The work from stage 4 is marked in the file with a comment that carries the image’s URL. If you fix a rule and convert the page again, the transcriptions you already paid for stay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and first run
&lt;/h2&gt;

&lt;p&gt;You need Python 3.11 or newer. It also works on 3.14.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/valcheffnet/scriptorium.git
&lt;span class="nb"&gt;cd &lt;/span&gt;scriptorium
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
.venv/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; scripts/requirements.txt
.venv/bin/python &lt;span class="nt"&gt;-m&lt;/span&gt; scriptorium doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows the pip line is &lt;code&gt;.venv\Scripts\pip install -r scripts\requirements.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Install through the requirements file, not with &lt;code&gt;pip install markitdown[all]&lt;/code&gt;. On Python 3.14 that command finds nothing to install and stops. The worse case is plain &lt;code&gt;pip install markitdown&lt;/code&gt; with no extras: it succeeds, and PDF and Office support is missing until the first time you need it. &lt;code&gt;doctor&lt;/code&gt; checks for exactly that, along with the Python version and the User-Agent it will send.&lt;/p&gt;

&lt;p&gt;The virtual environment takes about 420 MB. For what the tool does, that’s a lot, and most of it is MarkItDown’s dependencies.&lt;/p&gt;

&lt;p&gt;To see the output before pointing it at anything big, convert a small public site. It takes about twenty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; scriptorium &lt;span class="nt"&gt;--profile&lt;/span&gt; mkdocs convert https://www.mkdocs.org/sitemap.xml &lt;span class="nt"&gt;-o&lt;/span&gt; ./try
python &lt;span class="nt"&gt;-m&lt;/span&gt; scriptorium verify ./try
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open a few of the files. Always start with a pilot (&lt;code&gt;--limit 3&lt;/code&gt;) and read what comes out. The rules for stripping menus are tuned from what you see there, and tuning them after a full run means converting everything twice.&lt;/p&gt;

&lt;p&gt;One trap from Windows. Git Bash rewrites an argument that starts with a slash, like &lt;code&gt;--strip-prefix /docs&lt;/code&gt;, into a Windows path before Python sees it. The run finishes with exit code zero and does none of the stripping. Scriptorium now refuses a prefix that looks like a Windows path, but the habit is the point. After a run, check what it actually did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use it as a skill
&lt;/h3&gt;

&lt;p&gt;The repository is also a skill: &lt;code&gt;SKILL.md&lt;/code&gt; sits at its root, in the open skill format that Claude Code and Codex both read. Clone it straight into the folder where your agent looks for skills, and the agent can run the whole procedure for you, including a folder full of documents.&lt;/p&gt;

&lt;p&gt;For Claude Code, in your home folder, so it works in every project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/valcheffnet/scriptorium.git ~/.claude/skills/scriptorium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Codex the place is &lt;code&gt;~/.agents/skills/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/valcheffnet/scriptorium.git ~/.agents/skills/scriptorium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set up the Python environment inside that folder, the same way as above. The skill tells the agent how to use the tool, and the tool does the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  On somebody else’s server
&lt;/h2&gt;

&lt;p&gt;The defaults assume the site isn’t yours. It reads and follows &lt;code&gt;robots.txt&lt;/code&gt;, including &lt;code&gt;Crawl-delay&lt;/code&gt;. It waits a second between requests to the same host and backs off when the server says it’s busy. The User-Agent says what the tool is. It only pretends to be a browser if you pass &lt;code&gt;--as-browser&lt;/code&gt;, which is meant for your own sites.&lt;/p&gt;

&lt;p&gt;It also shows you what the site says about AI use. A &lt;code&gt;robots.txt&lt;/code&gt; with no ban can still declare that its content isn’t for AI training. A missing ban isn’t consent, so the tool reports that instead of stepping over it.&lt;/p&gt;

&lt;p&gt;The output is a local copy of material somebody else wrote. Every file keeps its source URL. There’s nothing in the tool for putting someone else’s documentation back on the public web, and that’s on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn’t done yet
&lt;/h2&gt;

&lt;p&gt;Scriptorium is that workflow rewritten to work on any site, with every one of those failures turned into a check or a test. The workflow behind it has copied the full Dynatrace documentation. The rewritten code itself has so far converted sites of up to 108 pages end to end. It comes with 32 offline tests that run in about a second, and five site profiles: generic, MkDocs, MkDocs Material, Docusaurus and Sphinx.&lt;/p&gt;

&lt;p&gt;It doesn’t download images for you; it gives you the list of the ones worth transcribing. It doesn’t crawl sites without a sitemap yet, because calendars and search pages generate endless URLs, and a crawler without clear stop rules walks straight into them.&lt;/p&gt;

&lt;p&gt;I’ve only run it on Windows 11. If you run the tests on macOS or Linux, I’d like to know the result, whether they pass or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The code can be rewritten in a day. The list of ways HTML-to-Markdown conversion loses content took weeks of real failures to put together. It’s in &lt;code&gt;references/traps.md&lt;/code&gt;, each one with how to spot it and what to do about it. If you already have a converted documentation set, read that file before you decide it’s fine.&lt;/p&gt;

&lt;p&gt;My rule from all this: when a tool says it succeeded, open the file.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/scriptorium-documentation-sites-to-markdown/" rel="noopener noreferrer"&gt;Scriptorium: Scraping Entire Documentation Sites and Document Archives to Markdown&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>python</category>
      <category>documentation</category>
      <category>ai</category>
    </item>
    <item>
      <title>Manual WordPress Migration, Step by Step</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:05:24 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/manual-wordpress-migration-step-by-step-pnl</link>
      <guid>https://dev.to/bozhidar_valchev/manual-wordpress-migration-step-by-step-pnl</guid>
      <description>&lt;p&gt;This WordPress migration guide moves a site from one host to another by hand. Files go over SFTP, the database through phpMyAdmin or WP-CLI, and DNS comes last. Every step has two versions. The first works with nothing more than a control panel and an SFTP client. The second is for readers who have SSH and want the job done in a few commands.&lt;/p&gt;

&lt;p&gt;I first wrote this in 2015 on my old blog. Some advice from that version did not age well. One tip in it could even break a site without any warning, so this is a rewrite rather than a repost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61r6yviwgvk82zuxo193.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F61r6yviwgvk82zuxo193.png" alt="WordPress migration flow: files and database from the old host to the new one, then DNS and SSL" width="800" height="442"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Numbers match the steps below.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  When a manual WordPress migration makes sense
&lt;/h2&gt;

&lt;p&gt;Migration plugins such as Duplicator, All-in-One WP Migration and Migrate Guru work well for most small sites. If one of them does the job for you, use it. Doing it by hand pays off when a site outgrows the plugin’s free tier or the new host’s upload limit. It also helps when a plugin migration has already failed and you need to see which step broke.&lt;/p&gt;

&lt;p&gt;It also teaches you what a WordPress migration actually is. Every plugin does the same steps below, only out of sight.&lt;/p&gt;
&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SFTP access to both hosts, and phpMyAdmin or SSH on both. Plain FTP sends your password in clear text, so use SFTP wherever the host offers it.&lt;/li&gt;
&lt;li&gt;An SFTP client. &lt;a href="https://filezilla-project.org/" rel="noopener noreferrer"&gt;FileZilla&lt;/a&gt; is fine; download it from the official site only. For SSH, Windows 10 and 11 already ship with OpenSSH, so &lt;code&gt;ssh&lt;/code&gt; works from any terminal without PuTTY.&lt;/li&gt;
&lt;li&gt;The site added as a domain or account on the new host. You also need an empty database, a database user and its password. If you are not sure how, ask the new host’s support.&lt;/li&gt;
&lt;li&gt;The PHP version of the old site. In wp-admin it is under Tools &amp;gt; Site Health &amp;gt; Info &amp;gt; Server. Set the new host to the same version or a newer one that your theme and plugins support.&lt;/li&gt;
&lt;li&gt;A lower DNS TTL. A day before the move, set the TTL of the domain’s A record to 300 seconds. The switch in step 9 then spreads within minutes instead of hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plan a content freeze as well. Comments, orders and form entries that reach the old site after the export never make it to the new one. For a blog that means a quiet hour. For a WooCommerce shop it means maintenance mode.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Export the database from the old host
&lt;/h2&gt;

&lt;p&gt;Without SSH, open phpMyAdmin and select the site’s database. Click Export, keep the Quick method and the SQL format, and click Go. If the database is large, choose the Custom method and set compression to gzip. phpMyAdmin imports &lt;code&gt;.sql.gz&lt;/code&gt; files directly, and the compressed file is often a tenth of the size.&lt;/p&gt;

&lt;p&gt;With SSH, go to the WordPress directory and let WP-CLI read the credentials from &lt;code&gt;wp-config.php&lt;/code&gt; for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/public_html
wp db &lt;span class="nb"&gt;export&lt;/span&gt; ~/site.sql
&lt;span class="nb"&gt;gzip&lt;/span&gt; ~/site.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this file even after the migration succeeds. It is your backup of the old site.&lt;/p&gt;

&lt;p&gt;While you are in the database, note the table prefix. The tables are named something like &lt;code&gt;wp_options&lt;/code&gt; and &lt;code&gt;wp_posts&lt;/code&gt;, and the part before &lt;code&gt;options&lt;/code&gt; is the prefix. You will need it in step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Copy the files
&lt;/h2&gt;

&lt;p&gt;Without SSH, connect to the old host with FileZilla and download the whole WordPress directory. First turn on Server &amp;gt; Force showing hidden files. Otherwise &lt;code&gt;.htaccess&lt;/code&gt; stays behind, and with it your redirects and any custom rules. Then connect to the new host and upload everything into the site’s directory. Press F5 if new files do not appear in the listing.&lt;/p&gt;

&lt;p&gt;Thousands of small files transfer slowly over SFTP. With a File Manager in cPanel or Plesk on both hosts, you can pack the directory into one archive. Move that single file and extract it on the new host.&lt;/p&gt;

&lt;p&gt;With SSH, you can stream the files from one server to the other through your own computer. No archive lands on either disk, and the two servers never need access to each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh user@old-host &lt;span class="s2"&gt;"tar czf - -C ~/public_html ."&lt;/span&gt; | ssh user@new-host &lt;span class="s2"&gt;"tar xzf - -C ~/public_html"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the full installation, core files included, rather than a fresh WordPress download plus &lt;code&gt;wp-content&lt;/code&gt;. Both approaches work. The full copy has one less place for a version mismatch to hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Edit wp-config.php on the new host
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;wp-config.php&lt;/code&gt; in the new site’s directory and fill in the database you created on the new host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_database_name'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_USER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_database_user'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new_database_password'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'localhost'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;DB_HOST&lt;/code&gt; with the new host instead of assuming &lt;code&gt;localhost&lt;/code&gt;. Managed hosting and cloud databases often use a separate hostname. A wrong value gives you “Error establishing a database connection” and nothing else.&lt;/p&gt;

&lt;p&gt;Further down the file, confirm that &lt;code&gt;$table_prefix&lt;/code&gt; matches the prefix you noted in step 1. If it does not, WordPress finds no tables with that prefix and offers to install itself from scratch. Also look for absolute paths the old host needed, for example in &lt;code&gt;WP_CONTENT_DIR&lt;/code&gt; or a cache plugin’s constant. Change them to the new server’s paths.&lt;/p&gt;

&lt;p&gt;With SSH, WP-CLI edits the file for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_NAME new_database_name
wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_USER new_database_user
wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_PASSWORD &lt;span class="s1"&gt;'new_database_password'&lt;/span&gt;
wp config &lt;span class="nb"&gt;set &lt;/span&gt;DB_HOST localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Import the database
&lt;/h2&gt;

&lt;p&gt;Without SSH, open phpMyAdmin on the new host and select the empty database. Click Import, choose the file from step 1 and click Go. If phpMyAdmin rejects it as too large, compress it with gzip first. If it is still over the limit, ask the host’s support to import it for you. Most will do it within the hour.&lt;/p&gt;

&lt;p&gt;With SSH, upload the file and import it from the WordPress directory. WP-CLI takes the credentials from the &lt;code&gt;wp-config.php&lt;/code&gt; you just edited, which is why step 3 comes first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/public_html
&lt;span class="nb"&gt;gunzip&lt;/span&gt; ~/site.sql.gz
wp db import ~/site.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One error comes up often when the old host runs MySQL 8 and the new one runs MariaDB: &lt;code&gt;Unknown collation: 'utf8mb4_0900_ai_ci'&lt;/code&gt;. MariaDB does not know that collation. Open the &lt;code&gt;.sql&lt;/code&gt; file in a text editor, replace every &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt; with &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;, and import again. This replacement is safe, because it touches table definitions and not your content.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Replace the old domain and paths
&lt;/h2&gt;

&lt;p&gt;Skip this step if the domain stays the same and nothing in the database points to the old server’s directories. Otherwise, this is the step where most manual WordPress migrations go wrong.&lt;/p&gt;

&lt;p&gt;The 2015 version of this guide told you to open the SQL file in Notepad++ and replace the domain there. Do not do that. WordPress and its plugins store widgets, theme options and plugin settings as serialized PHP. In that format the length of every string is written next to it, for example &lt;code&gt;s:22:"http://old-domain.com/"&lt;/code&gt;. Change the domain to one of a different length and the stored length no longer matches. WordPress cannot read the value and silently drops it, so widgets, menus and theme settings disappear with no error message.&lt;/p&gt;

&lt;p&gt;With SSH, WP-CLI understands serialized data. Run it with &lt;code&gt;--dry-run&lt;/code&gt; first, which only reports how many replacements it would make:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp search-replace &lt;span class="s1"&gt;'https://old-domain.com'&lt;/span&gt; &lt;span class="s1"&gt;'https://new-domain.com'&lt;/span&gt; &lt;span class="nt"&gt;--all-tables&lt;/span&gt; &lt;span class="nt"&gt;--skip-columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;guid &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
wp search-replace &lt;span class="s1"&gt;'https://old-domain.com'&lt;/span&gt; &lt;span class="s1"&gt;'https://new-domain.com'&lt;/span&gt; &lt;span class="nt"&gt;--all-tables&lt;/span&gt; &lt;span class="nt"&gt;--skip-columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;guid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same command fixes old server paths, such as &lt;code&gt;/home2/olduser/public_html&lt;/code&gt; replaced with the new site’s directory. Some plugins store those for caches, logs and uploads. After a move they point to a directory that no longer exists.&lt;/p&gt;

&lt;p&gt;Without SSH, use &lt;a href="https://github.com/interconnectit/Search-Replace-DB" rel="noopener noreferrer"&gt;Search-Replace-DB&lt;/a&gt; by interconnect/it. Upload its folder to the site’s root under a random name that nobody will guess. Open it in the browser, run a dry run, then the real replacement. Delete the folder right after. Left on the server, it gives anyone who finds it full write access to your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Test the new site before DNS changes
&lt;/h2&gt;

&lt;p&gt;The domain still points to the old host, but your own computer can be told otherwise. Add a line to your hosts file with the new server’s IP address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;203.0.113.10 example.com www.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows the file is &lt;code&gt;C:\Windows\System32\drivers\etc\hosts&lt;/code&gt; and needs an editor started as administrator. On macOS and Linux it is &lt;code&gt;/etc/hosts&lt;/code&gt;. Restart the browser and click through the site: the home page, a few posts, the contact form and the wp-admin login. The new host probably has no SSL certificate for the domain yet, so expect a certificate warning at this stage. Everything else should work.&lt;/p&gt;

&lt;p&gt;Remove the line when you are done. Otherwise you will never notice if DNS itself is wrong, because your computer keeps going straight to the new server.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Fix file permissions
&lt;/h2&gt;

&lt;p&gt;Files copied from another server sometimes arrive with the wrong permissions. WordPress expects 755 for directories and 644 for files. With SSH, run this in the WordPress directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;755 &lt;span class="o"&gt;{}&lt;/span&gt; +
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;644 &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;span class="nb"&gt;chmod &lt;/span&gt;640 wp-config.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;+&lt;/code&gt; at the end passes many files to one &lt;code&gt;chmod&lt;/code&gt; call. That is much faster than one call per file. &lt;code&gt;wp-config.php&lt;/code&gt; holds the database password, so it gets tighter permissions. If the site shows a white page after the last command, PHP runs under a different group on that host. Try 600, or ask support which value they recommend.&lt;/p&gt;

&lt;p&gt;Without SSH, FileZilla can do the same. Right click the WordPress directory, choose File permissions, enter 755, tick Recurse into subdirectories and select Apply to directories only. Repeat with 644 and Apply to files only.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Flush permalinks and caches
&lt;/h2&gt;

&lt;p&gt;In wp-admin, open Settings &amp;gt; Permalinks and click Save Changes without changing anything. This rebuilds the rewrite rules and fixes most “404 on every page except the home page” problems after a move. Then clear the cache of any caching plugin and of the CDN, if you use one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp rewrite flush
wp cache flush
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Switch DNS
&lt;/h2&gt;

&lt;p&gt;At your DNS provider, point the A record of the domain and of &lt;code&gt;www&lt;/code&gt; to the new server’s IP. Do the same for the AAAA record if you have one. Leave the MX records alone unless your email is moving as well. It is easy to break email with a migration that was only meant to move a website.&lt;/p&gt;

&lt;p&gt;With the TTL lowered a day earlier, most visitors reach the new server within minutes. Some resolvers ignore TTL, so keep the old hosting account running for at least a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Issue the SSL certificate
&lt;/h2&gt;

&lt;p&gt;Let’s Encrypt checks that the domain points to the server asking for the certificate. This step therefore waits until DNS has switched. Most control panels issue it with one click, often automatically. If the site then shows a padlock with a warning, some content still loads over &lt;code&gt;http://&lt;/code&gt;. Run the search-replace from step 5 again, from &lt;code&gt;http://example.com&lt;/code&gt; to &lt;code&gt;https://example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the browser shows “Your connection is not private” after the switch, my guide on &lt;a href="https://valcheff.net/fix-ssl-your-connection-is-not-private/" rel="noopener noreferrer"&gt;fixing that SSL error&lt;/a&gt; walks through the usual causes.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Check the error log
&lt;/h2&gt;

&lt;p&gt;Every host is configured a little differently. A plugin that ran quietly on the old server may complain on the new one. The host’s PHP error log is usually in the control panel. For WordPress’s own log, add these lines to &lt;code&gt;wp-config.php&lt;/code&gt; for a day or two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG_LOG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG_DISPLAY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Errors then go to &lt;code&gt;wp-content/debug.log&lt;/code&gt; without showing on the page. Set &lt;code&gt;WP_DEBUG&lt;/code&gt; back to false when you are done. On many servers the log file is readable from the web. If you cannot make sense of an error, send it to the new host’s support. That is part of what you pay them for.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the move
&lt;/h2&gt;

&lt;p&gt;Keep the old hosting and the database dump for at least a week. Watch the error log and check that contact forms still send email. Also open the site from a phone on mobile data, which uses a different DNS resolver than your home network. Cancel the old account only when a week has passed without surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about letting an AI agent do it?
&lt;/h2&gt;

&lt;p&gt;I tried. For this rewrite I wrote a prompt for a coding agent with shell access. I ran it with Claude Code against two throwaway WordPress servers in Docker. The agent did the first half well. It took an inventory of both hosts and noticed the non-default table prefix. After a backup of the database and the files, it streamed the files across. It filled in &lt;code&gt;wp-config.php&lt;/code&gt; without ever printing a password, and it stopped for approval where the prompt told it to.&lt;/p&gt;

&lt;p&gt;Then it reached the database import. Claude Code’s own safety layer refused to run it, even after I had approved the step. Overwriting a database on a remote server is exactly what these tools leave to a person. I think that is the right call. It does mean the riskiest step of the whole migration stays with you. An agent can save you time on the preparation and on checking the result afterwards. Plan to run steps 4 and 5 yourself.&lt;/p&gt;

&lt;p&gt;If this still feels like too much, hiring someone to do it is a perfectly reasonable choice. A broken migration usually costs more than a paid one.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/manual-wordpress-migration/" rel="noopener noreferrer"&gt;Manual WordPress Migration, Step by Step&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>linux</category>
      <category>mariadb</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>In Search of the Best A.I. Literary Translator – Detailed</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:08:52 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-detailed-30pk</link>
      <guid>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-detailed-30pk</guid>
      <description>&lt;p&gt;My wife reads faster than books get translated into Bulgarian. Some of the ones she wants don’t exist in Bulgarian and probably never will, because the market is small and the translation doesn’t pay for itself.&lt;/p&gt;

&lt;p&gt;So I tried machine translation. It came out unreadable: sentences that are grammatically correct and mean nothing, and dialogue punctuated the English way. My wife got to page thirty and gave up.&lt;/p&gt;

&lt;p&gt;I started with &lt;a href="https://www.deepl.com" rel="noopener noreferrer"&gt;DeepL&lt;/a&gt;, which everyone calls the best. It turned out to be all talk, and later in the test it came last of ten.&lt;/p&gt;

&lt;p&gt;The same happened with qwen3.8-max. I only included it because it is supposedly the best model for translation. It came second to last and was the only one that dropped parts of the text. All talk again.&lt;/p&gt;

&lt;p&gt;So I decided to run my own study and find out which AI literary translator really does the job best. In Bulgarian specifically, because a model that translates beautifully into Spanish isn’t necessarily good at Bulgarian, and the English-language rankings say nothing about it. Bulgarian has things English doesn’t. Gender follows the word the translator chose, so the same ship is correctly “she” if you called it a galley and “he” if you later called it a ship. The definite article sticks to the end of the word, there is a separate mood for retelling what you didn’t witness, and dialogue opens with a dash. At every one of those points the models differed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted to find out
&lt;/h2&gt;

&lt;p&gt;Which model, with what processing around it, can translate a work of fiction into Bulgarian well enough that someone finishes the book. I also wanted to know whether it is possible to measure at all who translates better.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested the AI translators
&lt;/h2&gt;

&lt;p&gt;I took Robert E. Howard’s story “Shadows in the Moonlight” from 1934. Howard died in 1936, so in the EU the text has been free since 2007. The story is 296 paragraphs and 12,079 words. As a yardstick I used an old published human translation: 305 paragraphs and 12,276 words, with every paragraph of the original matched to one in it.&lt;/p&gt;

&lt;p&gt;The translators were eight language models: gpt-6-astra, claude-opus-5, gemini-3.1-pro, gemini-3.8-flash, deepseek-v4-pro, grok-4.6, kimi-k3 and qwen3.8-max. Next to them I put DeepL, which is not a language model and serves as the floor. All of them ran through the same translation setup, and every model got byte for byte the same task. In the first round they all translated with no extra steps.&lt;/p&gt;

&lt;p&gt;I scored them three ways. First, mechanical checks over the whole text. Then a full read of all 296 paragraphs of every translation against the original, by the same reader, Fable 5.1, with serious, moderate and minor errors defined in advance. And finally a blind comparison: six passages from the ten translations, unlabelled and shuffled, in front of four judges from four different makers, Fable 5.1, Astra Pro, Gemini Pro and DeepSeek. Each of them has a relative among the translators, which is why there were four.&lt;/p&gt;

&lt;p&gt;There were a few rules. The judges don’t know who translated what. They score all the translations at once, so they measure on the same scale. Their instructions also explain how gender works in Bulgarian, because without that almost every translation looked wrong. And I checked whether the models had seen the published translation during training.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Whether a model works depends on who answers the call
&lt;/h3&gt;

&lt;p&gt;Most models are bought through a middleman, and behind one model name a different company can answer each call. For DeepSeek the catalogue lists 16 such companies, for Kimi 20. Behaviour changes with whichever company answers.&lt;/p&gt;

&lt;p&gt;Through one of the providers, deepseek-v4-pro cut the translation off at every ceiling I tried: 6,000, 16,000 and 48,000 tokens. Directly through DeepSeek’s own API it translated the whole story without a single break. gemini-3.8-flash through Google AI Studio returned 19 empty responses and stopped at 20% of the book, while through Google Vertex it went all the way. My first thought was censorship, because blood gets spilled in the story. I repeated the same call nine times and the empty response never came back. It was a temporary fault at the provider.&lt;/p&gt;

&lt;p&gt;One of the failures was mine: out of habit I set a ceiling of 32,000 tokens on DeepSeek, and the translation stopped exactly there. Since then I pin the provider explicitly and check it with a live call before I start the big book.&lt;/p&gt;

&lt;h3&gt;
  
  
  Counting can’t tell a good translation from a bad one
&lt;/h3&gt;

&lt;p&gt;The mechanical checks count words, dialogue lines, dashes, quotation marks, very short paragraphs, and missing or repeated paragraphs. One solid thing came out of it: none of the nine dropped a whole paragraph. After that, counting stops helping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiszqg1ujd21j05ckl7b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiszqg1ujd21j05ckl7b.png" alt="Identical on the numbers, four tiers apart on reading" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six of the models come out practically identical: length between 0.948 and 0.975 of the original and 150 or 151 dialogue lines with a dash. kimi-k3 has excellent numbers and a text full of words that don’t exist in Bulgarian, and in the blind comparison it is among the last. Reading split those same six models into four clearly different levels. Counting only catches the obvious failures.&lt;/p&gt;

&lt;p&gt;The full read produced these findings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Translation&lt;/th&gt;
&lt;th&gt;Findings, count&lt;/th&gt;
&lt;th&gt;Severe, count&lt;/th&gt;
&lt;th&gt;Moderate, count&lt;/th&gt;
&lt;th&gt;Minor, count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-pro&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-6-astra&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grok-4.6&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.8-flash&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deepseek-v4-pro&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human translation&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kimi-k3&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.8-max&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepL&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The totals aren’t quite comparable, because each reader decides for itself what to note. The serious column is comparable, because serious was defined the same way for everyone. DeepL is last with 16, and almost all of them are words with two meanings. “Quarter!”, which in a fight is a plea for mercy, becomes “Четвърт!”, the fraction. “Painter”, the rope on a boat, becomes “Художникът”, the artist. In one paragraph the word is wrong, in the next it is right, because every sentence is translated on its own. qwen3.8-max drops text and the reader feels the gap. DeepL writes smooth Bulgarian that says something else, and an editor without the original in front of them won’t catch it. According to the reader, bringing the DeepL translation up to print costs 60 to 70% of a new translation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which AI literary translator came out best in the first round
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" alt="Round one: ten candidates, three judges" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One judge gave only an order without points, so the chart shows three. The first three places are the same for every judge: gpt-6-astra, gemini-3.8-flash and deepseek-v4-pro. Further down, opinions differ by up to three places.&lt;/p&gt;

&lt;p&gt;I also checked how much each judge goes easy on its relative. Fable 5.1 doesn’t move claude-opus-5 at all, Astra Pro lifts gpt-6-astra by 0.33 places, DeepSeek lifts its own model by one place, and Gemini Pro lifts gemini-3.8-flash by 1.33 places and gives it 10 out of 10 on all six criteria, the only perfect score in the first round. The bias is real but small: if I remove each judge from the scoring of its own family, the top doesn’t move.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slower doesn’t mean better
&lt;/h3&gt;

&lt;p&gt;gemini-3.8-flash is the fastest, seven and a half minutes for the whole story, and it is second in the ranking. grok-4.6 works 10.5 times longer and comes in sixth or seventh. Waiting longer doesn’t buy a better translation.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a translation pipeline adds
&lt;/h3&gt;

&lt;p&gt;A plain translation is one pass through the text. A translation pipeline adds extra processing around that pass. I tried one on the four best models from the first round. The other four stopped here, because the same effort on a model four levels down answers nothing new.&lt;/p&gt;

&lt;p&gt;Each model’s plain translation and its pipeline versions were scored side by side by one blind judge, so compare the points only within one model. gpt-6-astra goes up from 6 to 8 points, gemini-3.1-pro from 7 to 8. gemini-3.8-flash drops from 8 to 7, deepseek-v4-pro from 7 to 6. For both drops the judges say the same thing: the translation becomes more precise in detail and in gender, but pays for it with literalisms and words that don’t exist. So there is no general answer to whether you should switch a pipeline on. It depends on the model, and the only way to find out is to try both.&lt;/p&gt;

&lt;p&gt;If you just add up the findings, the pipeline looks harmful: 90 before, 106 after. The breakdown shows the opposite. Moderate errors fall from 29 to 19, while minor remarks rise from 60 to 87, and that holds for each of the four models on its own. The pipeline trades errors of meaning for style remarks. Four out of four in the same direction happens by chance once in sixteen times, so it is a signal, but not yet proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  The human translation came in below the best machines
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" alt="The human translation, criterion by criterion" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the different scorings the published translation sits between sixth and eighth place out of ten. Its biggest loss is completeness, 4 out of 10, the lowest score after qwen3.8-max, which really does drop text. The translator condenses on purpose. Of 125 departures from the original, 66 are deliberate choices, 35 are errors of meaning and 23 are language or typography. The criterion, though, counts every cut as a loss, even when it works. The human translation has both errors and style, and this scale only sees the errors.&lt;/p&gt;

&lt;p&gt;Completeness isn’t the only reason. For Bulgarian the translation gets 6 out of 10, level with claude-opus-5 and below four of the machines: gpt-6-astra gets 9, gemini-3.8-flash gets 8. It keeps using the short definite article where Bulgarian grammar needs the full one. There is wrong verb government too, one case of it twice. About ten technical errors remain in the text, such as a line-break hyphen in the middle of a word, doubled words and Latin letters inside Cyrillic words. On gender it scores 8 out of 10, and the review finds one such slip: once Olivia turns masculine. These most likely come from scanning the text, not from the translation. The articles and the verb government, though, are in the translation itself.&lt;/p&gt;

&lt;p&gt;The translation is by Ivan Zlatarski, made around 1991 for a publisher, with no editing or proofreading, as was common at the time. Where and how it appeared in print is still unclear. It is probably in the 2003 Bulgarian collection “Конан: Пътят на славата” (Elf), which credits a different translator. That explains both kinds of errors: the articles come from a fast translation, and the technical errors from scanning the book.&lt;/p&gt;

&lt;h3&gt;
  
  
  The models hadn’t seen the translation before
&lt;/h3&gt;

&lt;p&gt;The published translation may have ended up in the data the models were trained on. Then they would simply be remembering it. I checked two ways. First, the longest common run of words: between a model and the human translation it averages 5.2 to 5.7 words, and between the models themselves 8.8. Then I gave the models a passage of the human translation and asked them to continue: the continuation resembles the human as much as it resembles their own translation. Nothing stands out. The stronger check, the same test on a book with no published Bulgarian translation, I skipped on purpose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6l0tvluygnilbq9e608y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6l0tvluygnilbq9e608y.png" alt="Longest wording a candidate shares with another text" width="799" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The second round: the judge sits down to translate
&lt;/h3&gt;

&lt;p&gt;After the first round, Fable 5.1 did all the reading and scoring. For two days it checked other people’s translations without handing in one of its own. So I had it translate the story too, with the same instructions as the other models. Like the four best models, it made a plain translation and versions through the pipeline, three texts in all.&lt;/p&gt;

&lt;p&gt;Fable couldn’t judge its own translation, so two outside judges scored it, gemini-3.8-flash and deepseek-v4-pro. And all the translations came back into play. The ten from the first round plus Fable’s three, thirteen texts at once in front of each judge, on the same six passages. It doesn’t work any other way: an eight given without knowing there is a nine in the same room isn’t the same eight.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdv33kntzm53i1hrg14p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdv33kntzm53i1hrg14p.png" alt="Round two: thirteen candidates, two outside judges" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gpt-6-astra stays first. Fable is right behind it. But I don’t count it as a win, because the conditions favoured it. It knew what it would be judged on: its rules were written after the first round and answer exactly the errors the others were penalised for. And once it caught and fixed its own error by itself, a Latin “p” inside a Bulgarian word. This round shows what a well-tuned process adds. Which model translates better is a different question.&lt;/p&gt;

&lt;p&gt;One of Fable’s pipeline versions came out byte for byte the same as its plain translation, because the extra processing changed nothing. That is how two identical texts ended up in the blind comparison without anyone planning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about measuring itself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The same reader doesn’t repeat its findings
&lt;/h3&gt;

&lt;p&gt;Some pipeline versions differ from the plain translations by only a few paragraphs, and the same reader read both. In practice that is the same text read twice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0esd3tznunh3f1x1h1e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0esd3tznunh3f1x1h1e.png" alt="One reviewer, the same text, read twice" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only 68% of the paragraphs with a remark on the first read have a remark on the second. On average 14 findings per text appear or vanish in paragraphs the pipeline never touched. So on 296 paragraphs the finding count has an error of about 10 to 15. The difference between 15 and 22 findings doesn’t exist. The difference between 15 and 92 does.&lt;/p&gt;

&lt;p&gt;The second round gave a cleaner experiment. Fable’s two identical texts went into the blind comparison under different letters. The judges saw them as two different candidates and gave them identical scores on all six criteria. So the disagreement comes from splitting the reading into several sittings, and when a judge sees everything at once, it is precise. Hence the rule: score everything in one call.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I almost published an error that doesn’t exist
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwshemt0ufmkddbmddenm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwshemt0ufmkddbmddenm.png" alt="What one rule in the instruction was worth" width="799" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first scoring instruction checked gender the way an English speaker would. In English the ship and the galley are both “it”, so a translation that says “ще го огледаме” (masculine) about the ship and “ще я потопя” (feminine) about the galley looked confused. By that rule almost every translation had a gender error, and for a while I had it written down as a finding. There is no such error. “Галера” is feminine, “кораб” is masculine, so both are right. Seven of the ten readers noticed it on their own, and that is how I noticed it too. After I rewrote the rule, “almost all” shrank to one real gender error in the blind comparison. The instruction has to carry the rules of the language even when most readers know them, because the rest will fill the table with errors that aren’t there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this study doesn’t prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everything rests on one story of 12,079 words. The next step is a longer book with the top three models.&lt;/li&gt;
&lt;li&gt;Most numbers after the first round come from a single reader, and that reader repeats only 68% of itself.&lt;/li&gt;
&lt;li&gt;The pipeline result is four out of four in the same direction. It is a signal until a second book says otherwise.&lt;/li&gt;
&lt;li&gt;Thirteen candidates on a ten-point scale squash the top. At one judge three of them share the ten.&lt;/li&gt;
&lt;li&gt;The stronger check of whether the models know the human translation, with a book that has no published translation, hasn’t been done yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So which model translates fiction best
&lt;/h2&gt;

&lt;p&gt;Overall, gpt-6-astra showed the best results in every round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvwljbc97kvho60lht7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvwljbc97kvho60lht7.png" alt="AI literary translator ranking: points out of 10" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gemini-3.8-flash came second, and it is by far the fastest of the eight.&lt;/p&gt;

&lt;p&gt;I also saw that a translation pipeline can change the result noticeably, in either direction depending on the model. If I set out to translate fiction myself, I would do it with a pipeline, tuned to the model I pick.&lt;/p&gt;

&lt;p&gt;The short version of this article is here: &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator/" rel="noopener noreferrer"&gt;In Search of the Best A.I. Literary Translator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator-detailed/" rel="noopener noreferrer"&gt;valcheff.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>translation</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>In Search of the Best A.I. Literary Translator</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:08:40 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-hhf</link>
      <guid>https://dev.to/bozhidar_valchev/in-search-of-the-best-ai-literary-translator-hhf</guid>
      <description>&lt;p&gt;My wife reads faster than books get translated into Bulgarian. Some of the ones she wants don’t exist in Bulgarian and probably never will, because the market is small and the translation doesn’t pay for itself.&lt;/p&gt;

&lt;p&gt;So I tried machine translation. It came out unreadable: sentences that are grammatically correct and mean nothing, and dialogue punctuated the English way. My wife got to page thirty and gave up.&lt;/p&gt;

&lt;p&gt;I started with &lt;a href="https://www.deepl.com" rel="noopener noreferrer"&gt;DeepL&lt;/a&gt;, which everyone calls the best. It turned out to be all talk, and later in the test it came last of ten.&lt;/p&gt;

&lt;p&gt;The same happened with qwen3.8-max. I only included it because it is supposedly the best model for translation. It came second to last and was the only one that dropped parts of the text. All talk again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested the AI translators
&lt;/h2&gt;

&lt;p&gt;So I decided to run my own study and find out which AI literary translator really does the job best. I took a Robert E. Howard story of about 12,000 words that also has a published human translation, and gave it to eight language models and to DeepL. Every one of them got the same instructions, word for word.&lt;/p&gt;

&lt;p&gt;For the scoring I picked four models from four different makers. Each has a relative among the translators and may go a little easy on it, so with four the bias gets diluted, and I can also see whether their scores agree. I set a few rules as well. The judges don’t know who translated what, and they score all the translations at once, so they measure on the same scale. I also checked whether the models had seen the published translation during training, because then they would simply be remembering it. I found no sign of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which AI literary translator came out on top
&lt;/h2&gt;

&lt;p&gt;Here is how they ranked. One judge gave only an order without points, so the chart shows three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs5wgtlh3gobe7zz0xjl3.png" alt="Round one: ten candidates, three judges" width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline adds
&lt;/h2&gt;

&lt;p&gt;Based on these results I also tried a translation pipeline, which adds extra processing around the plain translation.&lt;/p&gt;

&lt;p&gt;I ran the four best models again, this time through the pipeline. gpt-6-astra and gemini-3.1-pro gain one or two points, while gemini-3.8-flash and deepseek-v4-pro lose one. Each model was scored only against its own versions, so compare it only with itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The human translation, and the judge as a translator
&lt;/h2&gt;

&lt;p&gt;I also scored the published human translation. It came in below the best machines, and the main reason is completeness. The translator cuts on purpose: of 125 departures from the original, 66 are deliberate choices, and the scoring counts every one of them as a loss, even when the cut works. It also loses points on language. It keeps using the short definite article where Bulgarian grammar needs the full one: “кимериеца”, “вашия капитан”, “пътя ти”. There is wrong verb government too, such as “пусни ни борда”, and that one appears twice. About ten technical errors remain in the text, such as a line-break hyphen in the middle of a word (“удивле-ние”), doubled words and Latin letters inside Cyrillic words. These most likely come from scanning the text, not from the translation. For Bulgarian it scores 6 out of 10, against 9 for the best model.&lt;/p&gt;

&lt;p&gt;The translation is by Ivan Zlatarski, made around 1991 for a publisher, with no editing or proofreading. Where it appeared in print is still unclear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeegy8rpbr9i25b92wsn.png" alt="The human translation, criterion by criterion" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Finally I had one of the judges, Fable 5.1, translate the story too, plain and through the pipeline. Two outside models scored it together with all the other translations at once, so the scale was shared. It came right after gpt-6-astra, but I don’t count that as a win, because it was the only one that knew in advance what it would be judged on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdv33kntzm53i1hrg14p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdv33kntzm53i1hrg14p.png" alt="Round two: thirteen candidates, two outside judges" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So which model translates fiction best
&lt;/h2&gt;

&lt;p&gt;Overall, gpt-6-astra showed the best results in every round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvwljbc97kvho60lht7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3pvwljbc97kvho60lht7.png" alt="AI literary translator ranking: points out of 10" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gemini-3.8-flash came second, and it is by far the fastest of the eight.&lt;/p&gt;

&lt;p&gt;I also saw that a translation pipeline can change the result noticeably, in either direction depending on the model. If I set out to translate fiction myself, I would do it with a pipeline, tuned to the model I pick.&lt;/p&gt;

&lt;p&gt;Here you can read the detailed article about the research: &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator-detailed/" rel="noopener noreferrer"&gt;In Search of the Best A.I. Literary Translator – Detailed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://valcheff.net/in-search-of-the-best-ai-literary-translator/" rel="noopener noreferrer"&gt;valcheff.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>translation</category>
      <category>deepl</category>
    </item>
    <item>
      <title>Download YouTube playlist and channel videos with yt-dlp</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Sun, 13 Sep 2026 22:41:05 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/download-youtube-playlist-and-channel-videos-with-yt-dlp-30a4</link>
      <guid>https://dev.to/bozhidar_valchev/download-youtube-playlist-and-channel-videos-with-yt-dlp-30a4</guid>
      <description>&lt;p&gt;&lt;strong&gt;YT playlist downloader&lt;/strong&gt; is a single Python script that uses yt-dlp to download YouTube playlist videos in bulk. It handles entire channels and single videos too. It numbers the files in playlist order and remembers what it has fetched, so an interrupted run resumes where it stopped. A private or deleted video does not stop it either. The source is on GitHub under the MIT licence: &lt;a href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;YT_playlist_downloader_by_Valcheff&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Use it for material you have the right to download. That covers your own uploads, Creative Commons and public domain video, or anything the uploader permits. DRM protected streams are out of scope, and yt-dlp cannot handle them at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;yt-dlp can already do everything below. The catch is that doing it well takes a very long command line. Most people rebuild it from the help output every time and quietly leave out the parts they forget. The script fixes the flags in place so a bulk download behaves sensibly by default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwp257bt4rongbj1dhq6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwp257bt4rongbj1dhq6.png" alt="Download YouTube playlist videos with yt-dlp: numbered files, a resume archive, unavailable items skipped" width="800" height="358"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Files keep the playlist order, the archive makes re-runs resume, and an unavailable video is skipped.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Files land in playlist order
&lt;/h3&gt;

&lt;p&gt;Each filename starts with its position in the playlist. A 40 part lecture series therefore stays in sequence on disk instead of sorting alphabetically by title. It also sanitises titles for Windows and caps them at 200 characters, so long names stay under the path limit.&lt;/p&gt;
&lt;h3&gt;
  
  
  Interrupted downloads resume
&lt;/h3&gt;

&lt;p&gt;The script writes every completed video ID to an archive file in the output directory. Stop a 300 item playlist at item 180 and run the same command a week later. It continues at 181 rather than starting over. Partially written files resume at the byte level too, so a video cut off midway is not downloaded twice.&lt;/p&gt;
&lt;h3&gt;
  
  
  One dead video does not stop the run
&lt;/h3&gt;

&lt;p&gt;The script logs and skips private, deleted and geo-blocked items, and the rest of the playlist still downloads. Without this, a single unavailable video at position 47 ends a 200 item run. That is the main reason unattended downloads fail overnight.&lt;/p&gt;
&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.9 or newer&lt;/li&gt;
&lt;li&gt;yt-dlp installed with its default dependency group&lt;/li&gt;
&lt;li&gt;ffmpeg on PATH, for merging video with audio and for audio conversion&lt;/li&gt;
&lt;li&gt;deno 2.3.0 or newer, which yt-dlp uses to solve YouTube’s JavaScript challenge
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"yt-dlp[default]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The quotes and the bracketed group matter. A plain &lt;code&gt;pip install -U yt-dlp&lt;/code&gt; omits the challenge solver, which produces a misleading failure covered in the troubleshooting section.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to download YouTube playlist videos
&lt;/h2&gt;

&lt;p&gt;Start by listing the playlist. This prints the contents with numbers and durations and downloads nothing. It matters because a channel URL with 1100 videos looks identical to a playlist with 12 until you check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"https://www.youtube.com/playlist?list=PLxxxx"&lt;/span&gt; &lt;span class="nt"&gt;--list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then take the first two items as a smoke test. It catches a wrong output path or a missing ffmpeg before a long run does:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--items&lt;/span&gt; 1-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If those arrive correctly, drop the item range and download the whole playlist:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"https://www.youtube.com/playlist?list=PLxxxx"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Files arrive as &lt;code&gt;D:/Video/&amp;lt;playlist name&amp;gt;/01 - Title [videoID].mp4&lt;/code&gt; and so on. Pass &lt;code&gt;--flat&lt;/code&gt; if you would rather skip the per-playlist subfolder.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to download an entire YouTube channel
&lt;/h2&gt;

&lt;p&gt;Point it at the channel’s videos tab. Channels list newest first. Add &lt;code&gt;--reverse&lt;/code&gt; to make the oldest video file number one, which suits a course or a series:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"https://www.youtube.com/@channel/videos"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Channel"&lt;/span&gt; &lt;span class="nt"&gt;--reverse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Large channels are worth splitting into batches with &lt;code&gt;--items&lt;/code&gt;, for example &lt;code&gt;1-100&lt;/code&gt; today and &lt;code&gt;101:&lt;/code&gt; tomorrow. The archive file makes the split safe, because the second run skips anything the first one finished.&lt;/p&gt;

&lt;p&gt;To keep a channel mirrored, save the URLs in a text file and re-run the same command whenever you like. It downloads only the new uploads:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="nt"&gt;-f&lt;/span&gt; channels.txt &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  How to download audio only
&lt;/h2&gt;

&lt;p&gt;To download YouTube playlist audio without the video, add &lt;code&gt;--audio-only&lt;/code&gt;. For podcasts, lectures and music this cuts roughly ninety percent of the transfer. The script never touches the video stream:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Podcasts"&lt;/span&gt; &lt;span class="nt"&gt;--audio-only&lt;/span&gt; &lt;span class="nt"&gt;--audio-format&lt;/span&gt; opus &lt;span class="nt"&gt;--thumbnail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Choose opus rather than mp3 where your player allows it. YouTube already serves audio as opus or m4a. Requesting mp3 makes ffmpeg re-encode an already lossy stream, which adds a second round of loss. With opus you keep the original bytes. mp3 is worth it only for hardware that cannot play anything else. flac is counterproductive: it multiplies the file size without recovering detail YouTube has already discarded. The &lt;code&gt;--thumbnail&lt;/code&gt; flag embeds the video thumbnail as cover art, which is what most music players display.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to download videos with subtitles
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Course"&lt;/span&gt; &lt;span class="nt"&gt;--subs&lt;/span&gt; &lt;span class="nt"&gt;--auto-subs&lt;/span&gt; &lt;span class="nt"&gt;--sub-langs&lt;/span&gt; en,bg &lt;span class="nt"&gt;--embed-subs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Without &lt;code&gt;--auto-subs&lt;/code&gt; you get only captions the uploader typed by hand, which for most amateur video means none at all. Adding it brings in YouTube’s machine transcript, which is useful for searching through lectures even though it reads poorly. &lt;code&gt;--embed-subs&lt;/code&gt; writes the subtitles inside the video file instead of leaving separate &lt;code&gt;.srt&lt;/code&gt; files next to it. That matters for players that fail to pick up external subtitle files.&lt;/p&gt;
&lt;h2&gt;
  
  
  Choosing quality and file size
&lt;/h2&gt;

&lt;p&gt;The default ceiling is 1080p. It controls size more than quality, because 4K files are five to eight times larger than 1080p ones. The selector always takes the best format below the ceiling. A 480p source stays 480p, and the script never upscales it.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--max-height&lt;/span&gt; 2160 &lt;span class="c"&gt;# up to 4K&lt;/span&gt;
python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--max-height&lt;/span&gt; 720 &lt;span class="c"&gt;# small files&lt;/span&gt;
python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--max-height&lt;/span&gt; 0 &lt;span class="c"&gt;# no limit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Output goes into an mp4 container by default, which plays on essentially everything and spares media servers from transcoding. Switch to &lt;code&gt;--container mkv&lt;/code&gt; when you are pulling several subtitle languages or want chapters preserved, since mp4 handles both badly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Resuming and re-running
&lt;/h2&gt;

&lt;p&gt;The script records completed video IDs in &lt;code&gt;.downloaded.txt&lt;/code&gt; inside the output directory, and every later run skips them. Interrupt with Ctrl+C at any point and run the identical command afterwards to continue.&lt;/p&gt;

&lt;p&gt;Two consequences are worth knowing. Moving the finished files elsewhere does not clear the archive, so the script will not fetch them again. To download the same playlist again at a different quality, point &lt;code&gt;-o&lt;/code&gt; at a new directory or pass &lt;code&gt;--no-archive&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For an overnight run on a shared connection, cap the speed and slow the request rate:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python yt_playlist_dl.py &lt;span class="s2"&gt;"URL"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"D:/Video"&lt;/span&gt; &lt;span class="nt"&gt;--rate-limit&lt;/span&gt; 3M &lt;span class="nt"&gt;--sleep&lt;/span&gt; 1 &lt;span class="nt"&gt;--fragments&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Fragments deserve a word, because they are the main speed lever. A large video arrives in hundreds of fragments and the script downloads four at a time by default. Beyond roughly eight, YouTube throttles and intermittently returns HTTP 403. The retries that follow end up slower than a lower number would have been.&lt;/p&gt;
&lt;h2&gt;
  
  
  All options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Print the playlist contents and download nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-o DIR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Output directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--flat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip the per-playlist subfolder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--items SPEC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select items: &lt;code&gt;5-20&lt;/code&gt;, &lt;code&gt;3,7,12&lt;/code&gt;, &lt;code&gt;5:&lt;/code&gt;, &lt;code&gt;::2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--reverse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Walk the playlist from the end, for channels that list newest first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-height PX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resolution ceiling, 1080 by default, &lt;code&gt;0&lt;/code&gt; for unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--container&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mp4, mkv or webm for the merged file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--audio-only&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Download the audio stream only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--audio-format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;opus, m4a, mp3, flac, wav or vorbis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--subs&lt;/code&gt;, &lt;code&gt;--auto-subs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Subtitles, optionally including machine generated ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--embed-subs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write subtitles inside the video file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--thumbnail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Embed the thumbnail as cover art&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--sponsorblock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cut sponsor segments using the SponsorBlock database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--fragments N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parallel fragment downloads per video, 4 by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--rate-limit RATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Speed ceiling such as &lt;code&gt;3M&lt;/code&gt; or &lt;code&gt;800K&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--sleep SECONDS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pause between requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--cookies-from-browser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Use an existing browser session for your own private videos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-archive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ignore the resume archive and download everything again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Every download fails with “This video is not available”
&lt;/h3&gt;

&lt;p&gt;The message is wrong and the video is fine. YouTube hides its stream URLs behind a JavaScript challenge. yt-dlp solves it by running an external JavaScript runtime, deno by default, over solver scripts from the separate &lt;code&gt;yt-dlp-ejs&lt;/code&gt; package. When the solver is missing, no formats come back and yt-dlp reports the absence of formats as an unavailable video.&lt;/p&gt;

&lt;p&gt;Installing with the dependency group shown earlier pulls the solver in and fixes it. Official GitHub release binaries bundle it already, so this only bites pip installs. Verify both halves like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import yt_dlp_ejs; print('solver present')"&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; yt_dlp &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--simulate&lt;/span&gt; &lt;span class="s2"&gt;"https://www.youtube.com/watch?v=SOME_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The verbose output should report &lt;code&gt;JS runtimes: deno-&amp;lt;version&amp;gt;&lt;/code&gt; followed by &lt;code&gt;Solving JS challenges using deno&lt;/code&gt;. If it reports no supported runtime while deno is definitely installed, your terminal predates the installation. Its PATH is stale, so open a new one. Keep the two packages in step. yt-dlp raises the minimum solver version without announcing it and silently ignores an outdated one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Downloads are slow or return HTTP 403
&lt;/h3&gt;

&lt;p&gt;Lower &lt;code&gt;--fragments&lt;/code&gt; to 2 or 3 and add &lt;code&gt;--sleep 1&lt;/code&gt;. If it persists across a whole session rather than one video, YouTube is rate limiting your address. Waiting is the only real fix.&lt;/p&gt;
&lt;h3&gt;
  
  
  Merging fails or audio only produces nothing
&lt;/h3&gt;

&lt;p&gt;ffmpeg is not on PATH. Run &lt;code&gt;ffmpeg -version&lt;/code&gt; to confirm, and reopen the terminal if you installed it in this session.&lt;/p&gt;
&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Can it download an entire YouTube channel?
&lt;/h3&gt;

&lt;p&gt;Yes. Pass a channel videos URL such as &lt;a href="https://www.youtube.com/@channel/videos" rel="noopener noreferrer"&gt;https://www.youtube.com/@channel/videos&lt;/a&gt; and it walks every upload. Channels list newest first, so add –reverse for chronological order.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can it resume an interrupted playlist download?
&lt;/h3&gt;

&lt;p&gt;Yes. The script records completed video IDs in an archive file inside the output directory. Re-running the identical command skips them. Half finished files also resume at the byte level.&lt;/p&gt;
&lt;h3&gt;
  
  
  How many videos can it download at once?
&lt;/h3&gt;

&lt;p&gt;There is no built in limit, and playlists of several hundred items are the normal case. It fetches videos one after another while the fragments inside each video download in parallel. That keeps the request rate low enough to avoid throttling.&lt;/p&gt;
&lt;h3&gt;
  
  
  Do I need YouTube Premium?
&lt;/h3&gt;

&lt;p&gt;No. yt-dlp downloads the same streams an anonymous viewer receives. It never fetches ads, because YouTube delivers them separately from the video stream. A Premium account changes one thing: it makes the enhanced bitrate 1080p format available. yt-dlp labels that format Premium and prefers it automatically when you pass –cookies-from-browser. Everything else works identically without a subscription.&lt;/p&gt;
&lt;h3&gt;
  
  
  Can it download private or age restricted videos?
&lt;/h3&gt;

&lt;p&gt;It can use your own browser session through –cookies-from-browser. That covers your own unlisted and private uploads and age gated material. It does not bypass any restriction; it only reuses the access you already have.&lt;/p&gt;
&lt;h3&gt;
  
  
  Which audio format should I choose?
&lt;/h3&gt;

&lt;p&gt;opus, because YouTube already serves opus and nothing needs re-encoding. Choose mp3 only for players that cannot handle anything else.&lt;/p&gt;
&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;One script, one README documenting every option with the reasoning behind it, MIT licence: &lt;a href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;github.com/valcheffnet/YT_playlist_downloader_by_Valcheff&lt;/a&gt;. It calls yt-dlp as a Python library rather than shelling out. Progress reporting, error handling and the resume archive all run through the API.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/valcheffnet" rel="noopener noreferrer"&gt;
        valcheffnet
      &lt;/a&gt; / &lt;a href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;
        YT_playlist_downloader_by_Valcheff
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Download YouTube playlists and channels with yt-dlp: playlist-ordered filenames, resume across runs, and per-item error tolerance
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;YT playlist downloader&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A yt-dlp wrapper for pulling down whole YouTube playlists and channels without
having to remember twenty command line flags every time.&lt;/p&gt;
&lt;p&gt;It handles the parts you would otherwise write yourself. Filenames keep the
playlist order. Anything already downloaded gets skipped on the next run. And
when one item in a 200 video playlist turns out to be private, the other 199
still arrive.&lt;/p&gt;
&lt;p&gt;Meant for material you have the right to download. Your own uploads, Creative
Commons and public domain video, amateur footage, anything the uploader allows
It does not touch DRM protected streams and cannot be made to.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Prerequisites&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.9 or newer&lt;/td&gt;
&lt;td&gt;runs the script&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python --version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yt-dlp with the &lt;code&gt;default&lt;/code&gt; dependency group&lt;/td&gt;
&lt;td&gt;does the actual work&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python -m yt_dlp --version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ffmpeg on PATH&lt;/td&gt;
&lt;td&gt;merges video and audio, converts audio, embeds subtitles&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ffmpeg -version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deno 2.3.0 or newer&lt;/td&gt;
&lt;td&gt;solves YouTube's JavaScript challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/valcheffnet/YT_playlist_downloader_by_Valcheff" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The post &lt;a href="https://valcheff.net/download-youtube-playlist-yt-dlp/" rel="noopener noreferrer"&gt;Download YouTube playlist and channel videos with yt-dlp&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>youtube</category>
      <category>cli</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fix SSL “Your connection is not private”</title>
      <dc:creator>Bozhidar Valchev</dc:creator>
      <pubDate>Wed, 12 Nov 2025 17:52:39 +0000</pubDate>
      <link>https://dev.to/bozhidar_valchev/fix-ssl-your-connection-is-not-private-55ni</link>
      <guid>https://dev.to/bozhidar_valchev/fix-ssl-your-connection-is-not-private-55ni</guid>
      <description>&lt;p&gt;When Chrome or Firefox shows &lt;strong&gt;“Your connection is not private”&lt;/strong&gt; or &lt;strong&gt;“Your connection is not secure”&lt;/strong&gt; on your domain, your visitors cannot trust your site. In most cases this means there is a problem with the &lt;strong&gt;HTTPS / SSL configuration of your own website&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This guide is for &lt;strong&gt;website owners&lt;/strong&gt;. It shows you how to check and fix this error on your site by testing the SSL certificate, the domain configuration, and the certificate chain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg53zfvcr8emrkrl5pmw6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg53zfvcr8emrkrl5pmw6.png" alt="Your Connection is not Secure" width="800" height="245"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What this error means for your website
&lt;/h2&gt;

&lt;p&gt;Modern browsers show this warning when they detect that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the SSL certificate does not match the domain, or&lt;/li&gt;
&lt;li&gt;the certificate has expired, or&lt;/li&gt;
&lt;li&gt;the server does not provide a full, trusted certificate chain, or&lt;/li&gt;
&lt;li&gt;the site redirects to a host without a valid certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a visitor’s perspective, the message says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Do not enter passwords or credit card details here. Someone might read your traffic.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As a &lt;strong&gt;site owner&lt;/strong&gt;, you should treat this as a configuration bug on your side and fix it as soon as possible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Check your SSL certificate with an online tool
&lt;/h2&gt;

&lt;p&gt;First, verify that your SSL certificate is installed and working correctly.&lt;/p&gt;

&lt;p&gt;There are many free online tools that can test this. One simple and reliable option is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.sslshopper.com/ssl-checker.html" rel="noopener noreferrer"&gt;https://www.sslshopper.com/ssl-checker.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4d9zemxjz4dq9ohqzr8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4d9zemxjz4dq9ohqzr8.png" alt="Private Connection and Correct SSL" width="742" height="824"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Make sure the domain matches the certificate
&lt;/h2&gt;

&lt;p&gt;A very common cause of the “your connection is not private” error is a mismatch between the domain name and the SSL certificate.&lt;/p&gt;

&lt;p&gt;In the SSL report, check that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your &lt;strong&gt;exact domain&lt;/strong&gt; appears in the certificate,&lt;/li&gt;
&lt;li&gt;any subdomains you use over HTTPS are also covered,&lt;/li&gt;
&lt;li&gt;you understand the difference between &lt;code&gt;domain.com&lt;/code&gt; and &lt;code&gt;www.domain.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the certificate only covers &lt;code&gt;www.domain.com&lt;/code&gt; and your site loads from &lt;code&gt;domain.com&lt;/code&gt; (without &lt;code&gt;www&lt;/code&gt;), users may see a warning. The same happens in the opposite case.&lt;/p&gt;

&lt;p&gt;If you see a mismatch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;update the certificate so it includes all domains you actually use, or&lt;/li&gt;
&lt;li&gt;adjust your redirects so visitors always land on the hostname that the certificate covers.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 3: Check where your site actually loads from
&lt;/h2&gt;

&lt;p&gt;Next, make sure your domain points to the correct server and host.&lt;/p&gt;

&lt;p&gt;Look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the A/AAAA records in your DNS,&lt;/li&gt;
&lt;li&gt;any redirects your site performs,&lt;/li&gt;
&lt;li&gt;the final hostname in the browser address bar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typical problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your DNS points to an old server without a valid certificate;&lt;/li&gt;
&lt;li&gt;your site redirects from &lt;code&gt;https://yourdomain.com&lt;/code&gt; to another host like &lt;code&gt;https://some-default-host.yourprovider.com&lt;/code&gt; that uses a different certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In these cases the browser sees a certificate for another domain and shows the warning.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Review all checks in the SSL report
&lt;/h2&gt;

&lt;p&gt;In the SSL checker result you will see several checks. Go through them one by one and confirm they all pass.&lt;/p&gt;

&lt;p&gt;Pay attention to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validity:&lt;/strong&gt; has the certificate expired?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname:&lt;/strong&gt; does the certificate match your domain?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust:&lt;/strong&gt; is the certificate issued by a trusted Certificate Authority?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain issues:&lt;/strong&gt; is the server sending the full certificate chain?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something is wrong, the tool will usually show a red or orange warning, together with a short explanation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxk9li3ppc5626yr017y7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxk9li3ppc5626yr017y7.png" alt="Connection Not Private and broken SSL" width="725" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Install the full CA chain
&lt;/h2&gt;

&lt;p&gt;Your server should not only send your main SSL certificate but also the &lt;strong&gt;intermediate CA certificates&lt;/strong&gt; that link it to a root CA trusted by browsers. Together, this is known as the &lt;strong&gt;certificate chain&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the chain is incomplete, some visitors will see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Your connection is not private” in Chrome&lt;/li&gt;
&lt;li&gt;“Your connection is not secure” in Firefox&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the SSL checker report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;look for mentions of &lt;em&gt;intermediate certificates&lt;/em&gt; or &lt;em&gt;chain&lt;/em&gt;,&lt;/li&gt;
&lt;li&gt;check whether the tool says the chain is incomplete or missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the chain is not correct:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log in to your hosting control panel or server.&lt;/li&gt;
&lt;li&gt;Download the correct CA bundle from your SSL provider.&lt;/li&gt;
&lt;li&gt;Install the CA bundle together with your main certificate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your hosting documentation usually has a section called something like &lt;strong&gt;“How to install intermediate certificates / CA bundle”&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: What happens if no SSL certificate is installed at all
&lt;/h2&gt;

&lt;p&gt;In some cases there is simply no certificate for your domain. When that happens, the web server often falls back to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the first virtual host on the server, or&lt;/li&gt;
&lt;li&gt;a default system domain belonging to your hosting provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The visitor then sees a certificate that does not match your domain, and the browser shows a warning.&lt;/p&gt;

&lt;p&gt;In the SSL checker, this looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;certificate issued for another domain entirely, or&lt;/li&gt;
&lt;li&gt;a message that your domain does not have a valid certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwn3uggtutb4gk55recy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwn3uggtutb4gk55recy.png" alt="SSL checker report for a domain with a broken certificate chain" width="765" height="557"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To fix this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;issue a new SSL certificate for your domain, or&lt;/li&gt;
&lt;li&gt;enable the free certificate from your hosting provider (for example, Let’s Encrypt),&lt;/li&gt;
&lt;li&gt;then rerun the SSL check to confirm it works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  After you find the problem: how to fix it in practice
&lt;/h2&gt;

&lt;p&gt;When the SSL report shows an error or a warning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify which category it belongs to:

&lt;ul&gt;
&lt;li&gt;wrong or missing hostname,&lt;/li&gt;
&lt;li&gt;expired certificate,&lt;/li&gt;
&lt;li&gt;missing CA chain,&lt;/li&gt;
&lt;li&gt;wrong redirect or wrong server.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Fix the configuration:

&lt;ul&gt;
&lt;li&gt;reissue or renew the certificate,&lt;/li&gt;
&lt;li&gt;install the correct CA bundle,&lt;/li&gt;
&lt;li&gt;adjust DNS and redirects so the domain points to the right host.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Run the SSL check again to confirm that:

&lt;ul&gt;
&lt;li&gt;all checks are green,&lt;/li&gt;
&lt;li&gt;the certificate matches your domain,&lt;/li&gt;
&lt;li&gt;the certificate chain is complete.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are not comfortable doing this yourself, send the SSL report to your hosting support and ask them to fix the HTTPS configuration for your domain.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Quick answer:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The “Your connection is not private / secure” warning on your own website usually means a broken HTTPS configuration: a wrong or missing SSL certificate, expired certificate, or incomplete CA chain. Use an online SSL checker, verify that the certificate matches your domain and that the full certificate chain is installed, then fix DNS, redirects, and the certificate setup on your server.&lt;/p&gt;

&lt;p&gt;The post &lt;a href="https://valcheff.net/fix-ssl-your-connection-is-not-private/" rel="noopener noreferrer"&gt;Fix SSL “Your connection is not private”&lt;/a&gt; first appeared on &lt;a href="https://valcheff.net" rel="noopener noreferrer"&gt;Valcheff Net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ssl</category>
      <category>https</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
