<?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: Mirko Stahnke</title>
    <description>The latest articles on DEV Community by Mirko Stahnke (@findnix).</description>
    <link>https://dev.to/findnix</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%2F4085708%2F61747ce6-843d-41a9-883f-b60fb25e1256.png</url>
      <title>DEV Community: Mirko Stahnke</title>
      <link>https://dev.to/findnix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/findnix"/>
    <language>en</language>
    <item>
      <title>Moving a Kids' Search Engine to Its Own Brand and Domain: kids.findnix.eu to Suchpanda, Without Breaking a Single Link</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Wed, 21 Oct 2026 19:42:07 +0000</pubDate>
      <link>https://dev.to/findnix/moving-a-kids-search-engine-to-its-own-brand-and-domain-kidsfindnixeu-to-suchpanda-without-4pc4</link>
      <guid>https://dev.to/findnix/moving-a-kids-search-engine-to-its-own-brand-and-domain-kidsfindnixeu-to-suchpanda-without-4pc4</guid>
      <description>&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%2F9gpl4w8li7imeytjqp58.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%2F9gpl4w8li7imeytjqp58.png" alt="Suchpanda, the new mascot of the kids search engine" width="700" height="764"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Since July, our search engine for children lived at &lt;code&gt;kids.findnix.eu&lt;/code&gt;, a subdomain of the general-purpose search engine &lt;code&gt;findnix.eu&lt;/code&gt;. This month we moved it to its own name and its own domain: &lt;strong&gt;Suchpanda&lt;/strong&gt; at &lt;code&gt;suchpanda.de&lt;/code&gt;. This post explains why we split the two, and how we did the move without breaking a single old link, sitemap or admin tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a subdomain was the wrong home
&lt;/h2&gt;

&lt;p&gt;A kids' search engine and an adult-capable general search engine can share a codebase and a database, but they should not share an identity. These were our reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different promises.&lt;/strong&gt; &lt;code&gt;findnix.eu&lt;/code&gt; has accounts, advertising packages, a points system, a domain marketplace and a user area for site owners. The kids site has none of that: no tracking, no ads, nothing a child has to type into an account form. Parents who see the same brand on both will reasonably wonder where the line between them is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different trust boundaries.&lt;/strong&gt; Every filter, blocklist and reputation signal that applies to the parent can leak onto a subdomain, and the other way round. A children's service should not be one spam report away from being treated like the site it was spun off from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different rules for content.&lt;/strong&gt; The kids index is curated and checked by hand: no war, no violence, no sex education, no dangerous experiments. The general index is open by design. Two sets of rules belong under two names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A brand of its own.&lt;/strong&gt; The kids site already had two mascots, Uli and Nele. It now has a third, the Suchpanda ("search panda"), and a name that says what it does ("such" = search) without carrying the parent's brand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean operations.&lt;/strong&gt; Public pages, robots rules, sitemaps and webmaster accounts are separate per domain. Giving the kids site its own domain means its own &lt;code&gt;robots.txt&lt;/code&gt;, its own sitemaps and its own Search Console, Bing and Yandex properties.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The requirements for the move
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;suchpanda.de&lt;/code&gt; is the canonical home; &lt;code&gt;suchpanda.eu&lt;/code&gt; and &lt;code&gt;kids.findnix.eu&lt;/code&gt; redirect to it.&lt;/li&gt;
&lt;li&gt;Every old URL keeps working through a 301, including paths and query strings.&lt;/li&gt;
&lt;li&gt;Admin tools that are called across domains &lt;strong&gt;must not&lt;/strong&gt; be redirected. Our admin imports images and videos by calling two scripts on the old host with a shared key. They stay on &lt;code&gt;kids.findnix.eu&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No downtime, no broken sitemaps, no lost links in the database.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: domains and DNS
&lt;/h2&gt;

&lt;p&gt;We registered both domains and pointed &lt;code&gt;@&lt;/code&gt; and &lt;code&gt;www&lt;/code&gt; at the server. The &lt;code&gt;.eu&lt;/code&gt; domain came up within minutes. The &lt;code&gt;.de&lt;/code&gt; domain did not: the registry (DENIC) kept a placeholder nameserver entry (&lt;code&gt;not.connected.suchpanda.de IN A 127.0.0.1&lt;/code&gt;) while the registrar's own nameservers already answered correctly for the zone. A quick look with &lt;code&gt;whois -h whois.denic.de&lt;/code&gt; and &lt;code&gt;dig&lt;/code&gt; against the nameservers showed that the zone was fine and the nameserver update had simply not reached the registry. The registrar fixed it on their side, and a few minutes later the domain resolved everywhere.&lt;/p&gt;

&lt;p&gt;Useful checks that saved us hours of guessing:&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="c"&gt;# What does the registry say?&lt;/span&gt;
whois &lt;span class="nt"&gt;-h&lt;/span&gt; whois.denic.de &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"-T dn suchpanda.de"&lt;/span&gt;

&lt;span class="c"&gt;# Does each nameserver answer authoritatively with the same zone?&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;ns &lt;span class="k"&gt;in &lt;/span&gt;a.ns.example.net b.ns.example.net&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;dig +norec SOA suchpanda.de @&lt;span class="nv"&gt;$ns&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'flags:.* aa'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: one vhost, three names
&lt;/h2&gt;

&lt;p&gt;The kids site runs in its own PHP-FPM pool with an &lt;code&gt;open_basedir&lt;/code&gt; limited to its own directory. Instead of copying the site, we added &lt;code&gt;suchpanda.de&lt;/code&gt; and &lt;code&gt;www.suchpanda.de&lt;/code&gt; as &lt;strong&gt;aliases&lt;/strong&gt; of the existing web domain. That keeps one document root and one pool, and the Let's Encrypt certificate simply carries all three names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;v-add-web-domain-alias USER kids.findnix.eu suchpanda.de,www.suchpanda.de
v-add-letsencrypt-domain USER kids.findnix.eu suchpanda.de,www.suchpanda.de
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;suchpanda.eu&lt;/code&gt; became a separate, empty web domain with its own certificate and a forced 301 to &lt;code&gt;https://suchpanda.de$request_uri&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the redirect that keeps the admin tools alive
&lt;/h2&gt;

&lt;p&gt;All traffic for the old host is redirected, except two admin scripts and the ACME challenge path (otherwise certificate renewals for the old name would loop):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$sp_redir&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;kids.findnix.eu)&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$sp_redir&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;www.suchpanda.de)&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$sp_redir&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;"^/(admin-import-a|admin-import-b)\.php$")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$sp_redir&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;"^/\.well-known/acme-challenge/")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;$sp_redir&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sp_redir&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://suchpanda.de&lt;/span&gt;&lt;span class="nv"&gt;$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lives in a custom include next to the generated Hestia config, so it survives rebuilds. We tested it with &lt;code&gt;curl&lt;/code&gt; before and after: the old host answers &lt;code&gt;301&lt;/code&gt; for pages and &lt;code&gt;403&lt;/code&gt; (no key) or &lt;code&gt;200&lt;/code&gt; (with key) for the two admin scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: code, data and sitemaps
&lt;/h2&gt;

&lt;p&gt;The base URL of the kids app was one constant, but the old name was also hard-coded in about two dozen places. We wrote a small patch script with a dry run, per-file backups and a PHP lint pass, with rules to &lt;strong&gt;skip filesystem paths&lt;/strong&gt;, because they must not change when only the public name does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the kids base URL, the sitemap generators and &lt;code&gt;robots.txt&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the constants that build public URLs for books, tasks and study aids in the admin;&lt;/li&gt;
&lt;li&gt;71 absolute links stored in the database (&lt;code&gt;REPLACE()&lt;/code&gt; on &lt;code&gt;https://kids.findnix.eu/&lt;/code&gt; after a dump);&lt;/li&gt;
&lt;li&gt;the footer button on the main site, the RSS feed and the sitemap lines in the main site's &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more detail came from Yandex: in our setup it did not pick up the sub-sitemaps through the sitemap index. So every one of the 129 sub-sitemaps is now listed individually in &lt;code&gt;robots.txt&lt;/code&gt;, generated by a small cron job that reads the index every morning and rewrites the file only if it looks plausible and has changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong along the way
&lt;/h2&gt;

&lt;p&gt;While adding the new vhost, Hestia restarted a PHP-FPM version, and the systemd unit refused to start: &lt;code&gt;Failed to set up mount namespacing: Invalid argument&lt;/code&gt; (the sandbox option &lt;code&gt;PrivateDevices=true&lt;/code&gt; does not work in this container environment). Three sites on that PHP version returned 502 for under a minute until we disabled the sandbox options with a systemd drop-in. We have since applied the same drop-in to every service that uses them, and we now check &lt;code&gt;systemctl show php8.4-fpm -p PrivateDevices&lt;/code&gt; before any vhost operation. If you run Hestia in a container, this one is worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist if you are planning the same move
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Redirect &lt;strong&gt;by host&lt;/strong&gt;, not by path, and keep an explicit exception list for internal tools.&lt;/li&gt;
&lt;li&gt;Keep the old host's &lt;code&gt;robots.txt&lt;/code&gt; crawlable. If it is blocked, crawlers never see your 301s.&lt;/li&gt;
&lt;li&gt;Add the new domain to Search Console, Bing Webmaster Tools and Yandex Webmaster, and submit the sitemaps. Leave the old property in place until you have used the site-move tool.&lt;/li&gt;
&lt;li&gt;Reissue the certificate with all aliases once the new name resolves publicly, not before.&lt;/li&gt;
&lt;li&gt;Migrate absolute URLs stored in the database, and keep dumps.&lt;/li&gt;
&lt;li&gt;Expect consent banners to ask again: cookie consent is per domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;Suchpanda is live at &lt;code&gt;suchpanda.de&lt;/code&gt;. The old URLs redirect, the admin imports still run, sitemaps and &lt;code&gt;robots.txt&lt;/code&gt; use the new name, and the kids site finally has an identity that does not depend on its parent. If you want to see it, it is at &lt;a href="https://suchpanda.de" rel="noopener noreferrer"&gt;suchpanda.de&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>nginx</category>
      <category>php</category>
    </item>
    <item>
      <title>AI-Written Kids' Content Behind a Human Gate: Picture Books, Story Tasks and Practice Tables in Plain PHP</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:33:43 +0000</pubDate>
      <link>https://dev.to/findnix/ai-written-kids-content-behind-a-human-gate-picture-books-story-tasks-and-practice-tables-in-2p1c</link>
      <guid>https://dev.to/findnix/ai-written-kids-content-behind-a-human-gate-picture-books-story-tasks-and-practice-tables-in-2p1c</guid>
      <description>&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%2F88aaf4wj48535x28b832.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%2F88aaf4wj48535x28b832.jpg" alt="Overview of the tasks and study aids page on kids.findnix.eu" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you build learning content for children with an LLM, the model is the easy part. The hard parts are everything around it: who is allowed to see its output, how you catch its mistakes, and what you decide not to teach at all. This is a write-up of three pieces we added to &lt;a href="https://kids.findnix.eu" rel="noopener noreferrer"&gt;kids.findnix.eu&lt;/a&gt;, a curated, ad-free search engine for kids: AI-written picture books, "story tasks" for ages 9 to 14, and practice tables. All of it is plain PHP and MariaDB, no framework, no accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule everything else follows
&lt;/h2&gt;

&lt;p&gt;Kids never type into an AI. Nothing is generated while a child is looking at the page. Every text and every picture is produced in advance, reviewed by a person, and only then published. There is no chat box, no "ask the AI", and what a child types into an answer field never leaves their browser.&lt;/p&gt;

&lt;p&gt;We documented the reasons in the site's (German) wiki: a model can be confidently wrong, you cannot filter every answer to a free-form question, and a free text box invites children to type their name, school and worries into a third-party API. Pre-generated and approved content avoids all three. The &lt;a href="https://kids.findnix.eu/themen.php" rel="noopener noreferrer"&gt;Kinder KI topic cards&lt;/a&gt; work the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 1: picture books, with a translation exercise
&lt;/h2&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%2F1xfbm0giazw1bsj3c873.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%2F1xfbm0giazw1bsj3c873.jpg" alt="Picture books overview on kids.findnix.eu" width="799" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each book has a cover and eight pages with two to four short sentences each, for ages 6 to 9, on topics such as courage, friendship, feelings, being new in class and bedtime. They are written in German. Under every page a child can try to translate the text into English and then click "show solution"; the English text exists only as that answer key. A PDF with writing lines and solution pages is generated for printing.&lt;/p&gt;

&lt;p&gt;The generation pipeline is deliberately boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An LLM returns strict JSON: title, blurb, eight pages, tips, a note for parents, an English translation and a character sheet. Malformed or incomplete JSON is retried up to three times.&lt;/li&gt;
&lt;li&gt;A second pass derives the image prompts from the &lt;strong&gt;final&lt;/strong&gt; German text, one per page. When the model wrote the prompts alongside the story, they were sometimes shifted by one page, and they went stale the moment the text was edited.&lt;/li&gt;
&lt;li&gt;An image model draws the pages in one of six style presets (flat, watercolor, colored pencil, paper collage, gouache, pastel chalk) so the books do not all look alike.&lt;/li&gt;
&lt;li&gt;A human reads every text and checks every picture against its page. Anything wrong gets regenerated.&lt;/li&gt;
&lt;li&gt;Only the admin can publish. The PDF is built by a small dependency-free PHP writer (about 180 lines: A5 pages, core Helvetica, embedded JPEGs, text stays selectable).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What the review loop actually caught: pictures showing the whole cast in every scene, a "GYM" sign painted into an illustration despite a "no text" instruction, skin tone and clothing drifting between pages, German idioms that did not belong in a children's book, and a lost toy that was visible in the scenes where it was supposed to be missing. None of that is visible in the JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 2: story tasks that check the model's maths
&lt;/h2&gt;

&lt;p&gt;A story task is a short story and exactly three tasks that get harder from the first to the third, with a field for the child's own solution and a "show solution" button with the answer and the steps. There are seven subjects (maths, physics and IT, chemistry, biology, geography, history, German) and five levels for ages 9 to 14. The maths track climbs from money and time problems to the rule of three, percentages and simple equations.&lt;/p&gt;

&lt;p&gt;LLMs get arithmetic wrong, and a wrong answer key is the worst possible failure in a learning product. So every numeric task carries a machine-readable expression next to the prose answer, and the server re-computes it:&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="c1"&gt;// calc is a plain arithmetic expression such as "(12*4)+8"; result is the number the model claims&lt;/span&gt;
&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;kids_task_calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'calc'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// small shunting-yard evaluator, no eval()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'result'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nv"&gt;$warn&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'result mismatch'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;kids_task_text_has_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'answer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nv"&gt;$warn&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'answer does not state the result'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the check fails, the draft is discarded and regenerated, up to three times. It still does not replace a human recalculating each task, but it catches most slips before a person ever sees them.&lt;/p&gt;

&lt;p&gt;The funniest bug of the whole project: our first batch for geography, chemistry, biology and history came back as &lt;strong&gt;maths word problems in costume&lt;/strong&gt;. A history task asked the child to subtract 1450 from 2026. That is not what "history task" means. Only the maths track calculates now; the other subjects use matching, ordering, explaining and reading maps. Following a spec to the letter and still missing its point is not only a human talent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Piece 3: practice tables
&lt;/h2&gt;

&lt;p&gt;For each subject there are reference tables with a learn mode (everything visible) and a practice mode (one column hidden, the child types the answer and checks it). Currently 15 are live: all 193 UN member states with capitals and flags (including a "guess the flag" mode), the periodic table with all 118 elements, formula collections for maths and physics, a unit converter, a timeline of key dates, and German grammar tables for parts of speech, cases, articles, tenses and sentence parts, plus a few biology tables.&lt;/p&gt;

&lt;p&gt;A table is stored as plain text lines, one per row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Europe | Germany | Berlin | 1 | de
Geometry | Area of a rectangle | A = a · b ## a*b ## ab | 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;##&lt;/code&gt; part holds spellings that count as correct but are not shown. The interesting problem is deciding when a typed answer is right. Names are compared leniently (case, umlauts and accents do not matter, every capital of a country with several counts). Formulas need the opposite treatment. Our first version stripped everything except letters and digits before comparing, which made &lt;code&gt;a+b&lt;/code&gt; a correct answer for the area of a rectangle. Formula columns now use their own normalizer in which operators and brackets count and only the spelling of multiplication, powers and Greek letters is flexible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/²/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;^2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/π/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;·×*x&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// a*b, a x b, a·b and ab are the same&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;÷:&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;−–—&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/,/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The periodic table also has a colored overview: a CSS grid of 118 buttons in the classic layout, ten element groups with a legend that dims everything else, and a self-test mode that hides symbols or names until you tap a cell. The flags are SVG files from the MIT-licensed flag-icons project, self-hosted. Emoji flags were the first idea, but Windows shows them as two letters. The PDF writer only takes JPEG and the converters available on the server drew several flags wrongly, so the printable sheets leave flags out rather than ship wrong ones.&lt;/p&gt;

&lt;p&gt;One honest limitation: the flags are decorative images, so the "guess the flag" mode is not usable with a screen reader. The normal text mode is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately left out, and why
&lt;/h2&gt;

&lt;p&gt;There are no wars, no war zones, no sex education, and no description of biological or chemical processes that could be abused for weapons, explosives, poisons or drugs. In history the timeline contains inventions, discoveries and turning points; in geography, peaceful destinations and nature. The country table lists states and capitals and says nothing about conflicts.&lt;/p&gt;

&lt;p&gt;The reasons are practical rather than ideological. A child reading alone cannot ask anyone when something frightens them, and a three-sentence task cannot treat a war neutrally or with the context it needs. Sex education belongs first with parents and then with school, where timing, vocabulary and the maturity of the individual child can be taken into account. And anything that reads like a recipe for hazardous substances is a legal and safety risk, so chemistry stays with salt, sugar, water and sand, and electricity experiments use batteries only.&lt;/p&gt;

&lt;p&gt;Enforcement has three layers. The rules go into every LLM prompt. A word filter scans every text for terms about war, weapons and hazardous substances, and sex education, and a draft with a hit is discarded or flagged. The publish button refuses to publish while the filter has a hit. A human reads everything before it goes live. The filter matches words, not meaning, and it is strict on purpose: an older word list once flagged the German word for noodles because it contains "nude". We would rather rephrase a harmless sentence than let a hit through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;As of October 11, 2026: 33 picture books exist, 13 of them published; 36 story tasks across seven subjects are live; and there are 15 practice tables. The wiki documents the decisions in German: &lt;a href="https://wiki.findnix.eu/a/geschichten-bei-kids-findnix-eu-ki-mini-buecher-mit-bildern-zum-vorlesen-ausdrucken-und-uebersetzen-1791674214" rel="noopener noreferrer"&gt;picture books&lt;/a&gt;, &lt;a href="https://wiki.findnix.eu/a/aufgaben-geschichten-bei-kids-findnix-eu-lernen-mit-kleiner-geschichte-und-drei-aufgaben-9-bis-14-jahre-1791674214" rel="noopener noreferrer"&gt;story tasks&lt;/a&gt; (including the section on what is left out) and &lt;a href="https://wiki.findnix.eu/a/lernhilfen-bei-kids-findnix-eu-formelsammlungen-laender-periodensystem-und-mehr-zum-nachschlagen-und-ueben-1791674214" rel="noopener noreferrer"&gt;practice tables&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If there is one lesson to take away: let the model draft, make the server check what can be checked mechanically, and keep a person between the output and the child. Try it at &lt;a href="https://kids.findnix.eu/aufgaben.php" rel="noopener noreferrer"&gt;kids.findnix.eu/aufgaben.php&lt;/a&gt;; the content is German, because that is who it is for.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>education</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Tour of the findnix.eu User Area: 21 Modules for Site Owners and What They Do Under the Hood</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Mon, 05 Oct 2026 20:30:28 +0000</pubDate>
      <link>https://dev.to/findnix/a-tour-of-the-findnixeu-user-area-21-modules-for-site-owners-and-what-they-do-under-the-hood-2553</link>
      <guid>https://dev.to/findnix/a-tour-of-the-findnixeu-user-area-21-modules-for-site-owners-and-what-they-do-under-the-hood-2553</guid>
      <description>&lt;p&gt;findnix.eu is a GDPR-first metasearch engine with its own index, and the account area has grown into a toolbox for people who run websites. Most modules are free with an account; the advertising packages are the paid part. This is the full tour, grouped by what you want to get done, with a note on how each one works where that is interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get your site into the index and keep it healthy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Submit a sitemap.&lt;/strong&gt; Register a domain and a sitemap URL (XML, a PHP script that outputs XML, or an RSS 2.0 feed) or submit single URLs. Submissions are checked against spam lists and for duplicates. You prove ownership with a small token file on your server or a meta tag in your homepage &lt;code&gt;head&lt;/code&gt;, and the crawler picks the sitemap up on its next run. If your sitemap carries &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;description&amp;gt;&lt;/code&gt; per URL, we use them directly, so the page doesn't need to be fetched just for its metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sitemap Generator.&lt;/strong&gt; Enter a start URL, choose a crawl depth of 1 to 4 and up to 200 pages, edit the list it finds (add or remove URLs, set change frequency and priority per page) and download a standards-compliant &lt;code&gt;sitemap.xml&lt;/code&gt;. It skips external links, media files and typical admin and login paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNSBL Tester.&lt;/strong&gt; Checks a domain or IP against 20 DNS blacklists (Spamhaus ZEN, SpamCop, Barracuda, SORBS and others) and sends a certificate with a unique number by e-mail. You can schedule a repeat check every 7, 14, 28 or 31 days, and each certificate page offers an embeddable SVG badge. One implementation detail: the lookups go through a dedicated external DNS resolver, because shared hosting resolvers get rate-limited by some blacklist providers and then report false "listed" results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics.&lt;/strong&gt; A visitor counter with no cookies and no IP storage. You register a site, paste one script tag into your &lt;code&gt;head&lt;/code&gt;, and get views for today, the last 7 days and in total, the top pages of the last 30 days and the split by device type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reverse image search.&lt;/strong&gt; Upload an image (JPG, PNG, GIF or WebP up to 5 MB) or paste a URL, and the tool searches findnix's own image index plus IQDB for similar images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image protection.&lt;/strong&gt; Adds a text or logo watermark to many images at once, with five positions, adjustable size and opacity, and WebP as the default output format to save space. It is deliberately internal: there is no embed code, the protected images are just offered for download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools for building
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generator suite.&lt;/strong&gt; Nineteen small tools in one place: RSS feed, meta tags (including Open Graph and Twitter Card), a CSS generator with six sub-generators (button, box shadow, border radius, gradient, text shadow, animation), &lt;code&gt;.htaccess&lt;/code&gt;, password, QR code, color palette, Lorem Ipsum, Base64, slug, image placeholder, favicon links, &lt;code&gt;mailto:&lt;/code&gt; links, hash (MD5, SHA-1, SHA-256, SHA-512) and a short URL generator with a private list and click counter. Most of them run entirely in the browser, and nothing is sent to the server. The exceptions are the CSS gallery where you can share designs, the short URL generator, and the QR and placeholder tools, which call external APIs. The password generator uses the Web Crypto API, and the color and button generators show the WCAG contrast ratio live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Form generator.&lt;/strong&gt; Assemble a form from a palette of field types, label and reorder the fields, choose where it posts to (your own endpoint, Formspree and similar) or fall back to &lt;code&gt;mailto:&lt;/code&gt;, and copy or download a complete HTML document. It is 100% client-side: no form, field name or target address is ever stored. Reordering uses arrow buttons instead of drag and drop on purpose, so it works without a mouse, and the generated markup has real labels, &lt;code&gt;aria-required&lt;/code&gt; and an &lt;code&gt;aria-live&lt;/code&gt; status region.&lt;/p&gt;

&lt;h2&gt;
  
  
  Widgets for your own site
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rating widget.&lt;/strong&gt; Collect reviews with stars, an emoji mood, category ratings (service, quality, speed) and a free-text comment, as a floating button or embedded inline. Each registered site gets a dashboard with all ratings and a delete function, and one rating per IP and day is accepted to prevent repeat votes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Share widget.&lt;/strong&gt; Share buttons for Facebook, Instagram, X, Pinterest, Threads, TikTok, WhatsApp, LinkedIn and a copy-link button, in three designs and with your own colors, without cookies or stored IP addresses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad widget.&lt;/strong&gt; Embed a findnix banner (728×90, 300×250 or 300×100) on your own site and earn points for impressions and clicks from different visitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advertising and visibility
&lt;/h2&gt;

&lt;p&gt;Advertising is a flat rate with no bidding and at most three ads per page, always labeled as ads. &lt;strong&gt;Booking&lt;/strong&gt; offers three plans: Starter (currently 9 € per month, one ad, DE/AT/CH targeting), Business (49 €, up to three ads, EU-wide, real-time dashboard) and Enterprise (199 €, up to ten ads, all countries, priority placement). You pay by invoice and bank transfer. &lt;strong&gt;My ad&lt;/strong&gt; is where you write the ad itself (title, display URL, target URL, description, keywords, country codes) with a live preview, and ads are reviewed within about a day. &lt;strong&gt;Highlight&lt;/strong&gt; puts a frame and a star on your main domain when it shows up in results anyway; the organic order does not change, and it requires an approved sitemap. &lt;strong&gt;Sell a domain&lt;/strong&gt; gives a quick heuristic valuation (a 0 to 100 score, a category and a rough price range based on TLD, length, hyphens, digits and industry keywords) and lets you list the domain as an ad. Inquiries arrive in a mailbox inside your account, so your e-mail address never appears publicly.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AI Hub&lt;/strong&gt; is a bring-your-own-key portal at ai.findnix.eu: chat with Claude, ChatGPT, Gemini and Mistral, image generation with DALL-E and Stability AI, text to speech with ElevenLabs, a code assistant and video generation with Runway. You use your own provider keys and are billed by the provider, not by us. Keys are encrypted with AES-256-GCM, each with its own nonce, and a link in your account signs you in without a second registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Community and points
&lt;/h2&gt;

&lt;p&gt;Many actions earn points, and one point is worth 1.5 cents toward plans and highlights. The &lt;strong&gt;wishlist&lt;/strong&gt; takes feature ideas and pays 50 points when one is implemented. &lt;strong&gt;Donations&lt;/strong&gt; let you choose a charity, generate a donation receipt with bank details and a GiroCode QR, and pays 5 points per euro after you upload the transfer receipt and we check it. &lt;strong&gt;My journal&lt;/strong&gt; lets you submit articles and finds for the findnix.eu Journal, which are translated into 24 languages after approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your account
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Security &amp;amp; 2FA&lt;/strong&gt; adds TOTP two-factor authentication (FreeOTP, Google Authenticator or Authy) with single-use backup codes, plus password changes. &lt;strong&gt;Profile&lt;/strong&gt; holds your avatar, display name and billing details, and your personal invite link: every friend who registers through it earns you 30 points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it looks like this
&lt;/h2&gt;

&lt;p&gt;The pattern across all of it is the same: do as much as possible in the browser, store as little as possible on the server, and say so on the page. The modules are plain PHP and MariaDB like the rest of findnix.eu.&lt;/p&gt;

&lt;p&gt;The user area is at &lt;a href="https://findnix.eu/user/login.php" rel="noopener noreferrer"&gt;findnix.eu/user&lt;/a&gt;, and every module is described in more detail, in 24 languages, in the &lt;a href="https://findnix.eu/journal/kategorie/module" rel="noopener noreferrer"&gt;module section of the Journal&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tooling</category>
      <category>privacy</category>
      <category>php</category>
    </item>
    <item>
      <title>Building a 24-Language Journal in Plain PHP: AI Translation, Hash-Based Staleness, and a Points-Gated Community</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Mon, 05 Oct 2026 19:08:18 +0000</pubDate>
      <link>https://dev.to/findnix/building-a-24-language-journal-in-plain-php-ai-translation-hash-based-staleness-and-a-3a82</link>
      <guid>https://dev.to/findnix/building-a-24-language-journal-in-plain-php-ai-translation-hash-based-staleness-and-a-3a82</guid>
      <description>&lt;p&gt;findnix.eu is a GDPR-first metasearch engine with its own index. Search was never the whole story, so we added a &lt;strong&gt;Journal&lt;/strong&gt;: articles, a weekly digest, "finds" (sites worth a look), user questions and answers, and a profile page for every domain in the index. It's plain PHP 8.4 and MariaDB, no framework, in the same codebase as everything else. These are the decisions that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  One table, four post types, translations on the side
&lt;/h2&gt;

&lt;p&gt;Articles, digests, finds and questions share one table with an &lt;code&gt;ENUM&lt;/code&gt; type column. Translations live in their own table keyed by &lt;code&gt;(post_id, lang)&lt;/code&gt;, so the original post never changes shape and a missing language is just a missing row. German is the source language; the other 23 EU languages are translations. Each language version gets its own URL (&lt;code&gt;/journal/fr/some-slug&lt;/code&gt;) with &lt;code&gt;hreflang&lt;/code&gt; links, and the sitemap lists every version with its translated title and description, so the translated pages end up in our own search index too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Markdown without a library
&lt;/h2&gt;

&lt;p&gt;The renderer is about 100 lines. The one rule that keeps it safe: escape everything first, then generate only the tags we allow.&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="nv"&gt;$t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;htmlspecialchars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ENT_QUOTES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;ENT_SUBSTITUTE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// only now turn [text](url) into &amp;lt;a&amp;gt;, and only for http(s), mailto or local paths&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raw HTML in a post simply shows up as text. Links from user content get &lt;code&gt;rel="nofollow ugc"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translating HTML with an LLM, and not trusting the result
&lt;/h2&gt;

&lt;p&gt;Posts are rendered to HTML once, and that HTML is what we translate: split at &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt;–&lt;code&gt;&amp;lt;h4&amp;gt;&lt;/code&gt; boundaries into chunks of a few thousand characters, one &lt;code&gt;gpt-4o-mini&lt;/code&gt; call per chunk, with the instruction to keep every tag and attribute and translate only visible text. Title and teaser go in a separate call that returns JSON.&lt;/p&gt;

&lt;p&gt;The important part is what happens afterwards. An article is attacker-controlled text as far as the model is concerned: a user post could contain instructions, and the model might obey them and emit markup. So the translated HTML goes through a &lt;code&gt;DOMDocument&lt;/code&gt; whitelist (a short list of tags and attributes, URL schemes checked) before it is stored. If the model produces a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, it never reaches a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Is this translation stale?" is not an &lt;code&gt;updated_at&lt;/code&gt; question
&lt;/h2&gt;

&lt;p&gt;First version: compare the translation's timestamp with the post's &lt;code&gt;updated_at&lt;/code&gt;. It broke immediately, because &lt;code&gt;updated_at&lt;/code&gt; moves when you pin a post, change its status, or bump the view counter. Every pin would have re-translated 23 languages.&lt;/p&gt;

&lt;p&gt;Now each translation stores a hash of the source text it was made from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;MD5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;body_md&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A translation is current if its stored hash equals the post's current hash. Pinning, status changes and view counts don't matter any more. (View counters are updated with &lt;code&gt;updated_at = updated_at&lt;/code&gt; anyway, so they don't touch the timestamp.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Two triggers, one idempotent worker
&lt;/h2&gt;

&lt;p&gt;Translating all languages for a post takes a minute or more, which is too long for a save request. We use two triggers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In the editor:&lt;/strong&gt; after saving a published post, the admin page loops over the missing languages from the browser, one request per language, with a progress bar. Instant feedback, no server timeout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cron job every 15 minutes&lt;/strong&gt; picks up everything the editor didn't: a digest approved from the list view, a user submission approved by a moderator. It takes a file lock so runs never overlap, skips posts edited in the last ten minutes (the editor is probably handling those), stops after five consecutive failures instead of hammering a broken API, and stays silent when there is nothing to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both call the same function and write with an upsert, so running them twice is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug worth sharing: Slovak came back as Slovenian
&lt;/h2&gt;

&lt;p&gt;Our first batch of translations looked fine until we noticed that the Slovak and Slovenian titles were identical, for 8 of 9 posts. The prompt said "Slovenčina (sk)". The model read the name, not the code, and answered in Slovenian.&lt;/p&gt;

&lt;p&gt;The fix was to use English language names plus an explicit contrast in the prompt: &lt;em&gt;"Slovak (the language of Slovakia; NOT Slovenian and NOT Czech)"&lt;/em&gt;, and the mirror image for Slovenian, Czech, Croatian, Danish and Swedish. Re-running the nine posts produced correct Slovak.&lt;/p&gt;

&lt;p&gt;How we found it matters more than the fix. Asking the model to identify the language of each translation caught only 2 of the 8 wrong ones. A plain &lt;code&gt;GROUP BY title HAVING COUNT(*) &amp;gt; 1&lt;/code&gt; across languages caught all of them. For batch-generated content, cheap invariants beat asking an LLM to grade itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Community features, all on one points ledger
&lt;/h2&gt;

&lt;p&gt;Every user action (a comment, an answer, a question, a submitted article, a domain review) goes through the same two helpers: one that grants points with a daily cap, and one that revokes them. The rules we settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Points are granted only when the content becomes &lt;strong&gt;visible&lt;/strong&gt;, never at submit time.&lt;/li&gt;
&lt;li&gt;Spam or deletion &lt;strong&gt;revokes&lt;/strong&gt; exactly what was granted. Each comment remembers its points for that reason.&lt;/li&gt;
&lt;li&gt;A user's first comment, anything containing a link, and anything the blacklist flags goes to a moderation queue. Once a user has visible content, they are "trusted" and skip the queue.&lt;/li&gt;
&lt;li&gt;Answering your own question earns nothing, and the best-answer bonus can't be given to yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The amounts live in a settings table, so we can tune them in the admin without a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain profiles without scanning 37 million rows
&lt;/h2&gt;

&lt;p&gt;Every domain in the index gets a page with its page count, sitemap count, first-seen date and a few sample pages. Counting rows per domain in a 37-million-row table on every request is a non-starter, so the count is capped inside a subquery and the result is cached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fnx_sitemap_urls&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'aktiv'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;adult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Large domains show "100,000+", which is honest and costs one index range scan. A batch job refreshes the numbers slowly, with a pause between domains, rather than all at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more false positive
&lt;/h2&gt;

&lt;p&gt;Our spam blacklist matched keywords as substrings. The keyword &lt;code&gt;anal&lt;/code&gt; blocked &lt;code&gt;analyst.nl&lt;/code&gt;, &lt;code&gt;analytics-agentur.ch&lt;/code&gt; and &lt;code&gt;psykoanalyse.no&lt;/code&gt;; &lt;code&gt;sex&lt;/code&gt; would have blocked &lt;code&gt;sussex.ac.uk&lt;/code&gt;. We added a &lt;code&gt;=word&lt;/code&gt; syntax that matches only at word boundaries (no letter or digit directly before or after) and left long, unambiguous terms as substrings so that concatenated spam domains still get caught. Of the 27 indexed domains the old filter had hidden, all 27 were false positives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store a content hash with derived data instead of comparing timestamps.&lt;/li&gt;
&lt;li&gt;Treat LLM output as untrusted markup, even when you wrote the input.&lt;/li&gt;
&lt;li&gt;Name the language in the prompt, not just its code, and add a "not X" clause for close neighbours.&lt;/li&gt;
&lt;li&gt;Cheap data invariants find batch bugs that "ask the model" checks miss.&lt;/li&gt;
&lt;li&gt;Put every reward or privilege behind one ledger with caps and a way to take it back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Journal is live at &lt;a href="https://findnix.eu/journal" rel="noopener noreferrer"&gt;findnix.eu/journal&lt;/a&gt;, and the questions section is at &lt;a href="https://findnix.eu/journal/fragen" rel="noopener noreferrer"&gt;findnix.eu/journal/fragen&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>i18n</category>
      <category>ai</category>
    </item>
    <item>
      <title>Bring Your Own Key: Building an AI Portal That Never Touches the Billing</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Sun, 23 Aug 2026 18:47:18 +0000</pubDate>
      <link>https://dev.to/findnix/bring-your-own-key-building-an-ai-portal-that-never-touches-the-billing-2noh</link>
      <guid>https://dev.to/findnix/bring-your-own-key-building-an-ai-portal-that-never-touches-the-billing-2noh</guid>
      <description>&lt;h2&gt;
  
  
  Bring-your-own-key, not another subscription
&lt;/h2&gt;

&lt;p&gt;Every few months another "all AI models in one place" product shows up, and almost all of them work the same way: you pay them a subscription, and they resell access to Claude, GPT-4, Gemini and friends at a markup. That's a reasonable business, but it's not the only way to build this, and it's not the way &lt;a href="https://ai.findnix.eu" rel="noopener noreferrer"&gt;ai.findnix.eu&lt;/a&gt; works.&lt;/p&gt;

&lt;p&gt;AI Hub is a bring-your-own-key portal: you paste in your own Anthropic, OpenAI, Google, Mistral, Stability AI, ElevenLabs and Runway keys, and the portal just gives you one consistent interface — chat, image generation, text-to-speech, a code assistant, video generation — over whichever of those you've connected. Billing happens directly between you and the provider. The portal itself charges nothing for API usage, because it never touches the money side at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys are the one thing that has to be paranoid
&lt;/h2&gt;

&lt;p&gt;The whole trust model of a BYOK product rests on one thing: can users believe their API keys — which are effectively bearer tokens for their own paid accounts — are safe. Every key is encrypted with AES-256-GCM before it touches the database, with a unique nonce per key (so two identical keys never produce identical ciphertext, which matters for preventing pattern analysis against the stored blobs). The master key lives in a config file that's explicitly blocked from direct HTTP access at the nginx level, as a second layer independent of PHP actually executing the file correctly.&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;encryptApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hex2bin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MASTER_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$tag&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'aes-256-gcm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_RAW_DATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'enc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cipher&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'nonce'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nonce&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic — GCM gives you authenticated encryption for free, so a tampered ciphertext fails to decrypt rather than silently returning garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two identities, one login, no forced choice
&lt;/h2&gt;

&lt;p&gt;AI Hub is a sibling project to &lt;a href="https://findnix.eu" rel="noopener noreferrer"&gt;findnix.eu&lt;/a&gt;, a GDPR-focused EU search engine, and that created an actual product question: should AI Hub have its own user accounts, or piggyback on findnix.eu's existing ones?&lt;/p&gt;

&lt;p&gt;The answer ended up being both, deliberately. At signup you choose: a standalone AI Hub account, or one linked to your findnix.eu account with a shared login. Existing findnix.eu users don't have to fill out a second registration form at all — a link in their findnix.eu account silently provisions a linked AI Hub identity and drops them straight into the dashboard, authenticated.&lt;/p&gt;

&lt;p&gt;That "silently provisions" step runs over a short-lived, single-use signed token rather than any shared session or cookie trickery between the two domains:&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;consumeSsoToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* look up token, reject if used or older than 60s */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;mark&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$aiUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* look up existing linked account for this findnix user */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$aiUserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$aiUserId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// first time: silently create a linked AI Hub account, no form involved&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createLinkedAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'fnx_user_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For linked accounts, password checks go straight to findnix.eu's own password hash rather than a duplicated one — one password, one source of truth, even though the two products keep separate account tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Least privilege, even between your own apps
&lt;/h2&gt;

&lt;p&gt;Because AI Hub and findnix.eu now share a physical database (AI Hub's tables live in the same schema with their own prefix, rather than a separate database), it would have been easy to just reuse findnix.eu's own full-access database credentials for AI Hub too. Instead AI Hub gets its own MySQL user, scoped to &lt;code&gt;SELECT, INSERT&lt;/code&gt; on the shared users table (enough to look up and create linked accounts) and full access only to its own tables. It's internal plumbing nobody using the product will ever see, but it means a bug in a comparatively young, less-battle-tested part of the stack can't reach past its own tables into the main product's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The provider list — Claude, GPT-4o, Gemini, Mistral, DALL-E, Stability AI, ElevenLabs, Runway — covers the obvious ground, but "bring your own key" as a model scales naturally to whatever shows up next; adding a provider is a new small API wrapper file and an entry in a provider list, not a pricing renegotiation.&lt;/p&gt;

&lt;p&gt;If the BYOK model is something you'd rather use than another flat-rate AI subscription: &lt;a href="https://ai.findnix.eu" rel="noopener noreferrer"&gt;ai.findnix.eu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Community Wiki Around a Privacy-First Search Engine</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Sat, 22 Aug 2026 23:05:42 +0000</pubDate>
      <link>https://dev.to/findnix/building-a-community-wiki-around-a-privacy-first-search-engine-1pnd</link>
      <guid>https://dev.to/findnix/building-a-community-wiki-around-a-privacy-first-search-engine-1pnd</guid>
      <description>&lt;h2&gt;
  
  
  The problem: a search engine needs more than a search box
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://findnix.eu" rel="noopener noreferrer"&gt;findnix.eu&lt;/a&gt; is a EU-based, privacy-first search engine — "Find alles, speichere nix" (find everything, store nothing) is the tagline. Once real users started using it, the support questions started too: how does the spam filter work, why isn't my site indexed yet, what data do you actually store, how do I report a fake shop I found in the results?&lt;/p&gt;

&lt;p&gt;Answering the same questions over email doesn't scale, and a single flat FAQ page doesn't either once you also want space for longer-form, kid-safe explainer articles and general knowledge entries contributed by the community. So instead of bolting a FAQ page onto the main site, we split it out into its own project: &lt;strong&gt;wiki.findnix.eu&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three categories, one wiki
&lt;/h2&gt;

&lt;p&gt;The wiki is organized into three top-level groups, each with its own short-link in the header:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FAQ findnix&lt;/strong&gt; — how the search engine, the spam filter, the DNSBL cross-check, and the points/rewards system work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kids findnix&lt;/strong&gt; — safe, curated explainer content that doubles as background material for &lt;a href="https://kids.findnix.eu" rel="noopener noreferrer"&gt;kids.findnix.eu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wiki findnix&lt;/strong&gt; — general knowledge entries submitted by the community, reviewed before they go live&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every entry goes through a &lt;code&gt;submit.php&lt;/code&gt; form and lands in a moderation queue with &lt;code&gt;status='approved'&lt;/code&gt; as the gate — nothing is public until someone actually looks at it. That single status column is doing a lot of quiet work: it's the difference between "anyone can publish anything" and "anyone can propose something."&lt;/p&gt;

&lt;h2&gt;
  
  
  Translation without an embedded widget
&lt;/h2&gt;

&lt;p&gt;The wiki needs to serve an EU-wide audience, and hard-coding 20+ language files for community-submitted content isn't realistic for a small team. The pragmatic fix: a language dropdown that builds a one-time link to Google's &lt;code&gt;translate.goog&lt;/code&gt; proxy for the current page, entirely client-side, on click:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wikiGoTranslate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.translate.goog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_x_tr_sl=de&amp;amp;_x_tr_tl=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;_x_tr_hl=de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No translation script is embedded or running in the background on the page itself — it's a single click handler that computes a URL and navigates. That turned out to matter more than expected: an earlier version embedded Google's live translation widget directly on the page, and it collided with the sticky header (the widget would inject its own top bar and push the layout around). Moving to a proxy-link model instead of an embedded widget removed an entire class of layout bugs, and it means one less third-party script running continuously against visitors' browsers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Old links still have to work
&lt;/h2&gt;

&lt;p&gt;Categories used to be addressed by numeric ID (&lt;code&gt;?cat=12&lt;/code&gt;). Once the wiki grew nice slug-based URLs (&lt;code&gt;?cat_slug=faq-findnix&lt;/code&gt;), old bookmarks and inbound links with the numeric form still needed to resolve — so the numeric path does a lookup and issues a real 301 redirect to the slug URL rather than silently rendering the same content on two different URLs:&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$catId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$catSlug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT slug FROM categories WHERE id=?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$lookup&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bindValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$catId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SQLITE3_INTEGER&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$lookup&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SQLITE3_ASSOC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Location: /k/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&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="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small detail, but it's the difference between a wiki that quietly accumulates duplicate/dead URLs over years and one that doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The wiki is deliberately simple: SQLite-backed, PHP, no JS framework, no build step. That's a feature, not a limitation — it means the barrier to contributing an entry, or to reading the code and understanding exactly what happens to a submission, stays low. As findnix.eu grows, the plan is to keep leaning on the community to write the FAQ and knowledge entries rather than trying to centrally author everything — the people hitting an edge case are usually the best people to explain it to the next person who hits it.&lt;/p&gt;

&lt;p&gt;If you want to see it in action (or contribute an entry), it's live at &lt;a href="https://wiki.findnix.eu" rel="noopener noreferrer"&gt;wiki.findnix.eu&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a Search Engine for Kids That Doesn't Track Them</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Sat, 22 Aug 2026 23:05:15 +0000</pubDate>
      <link>https://dev.to/findnix/building-a-search-engine-for-kids-that-doesnt-track-them-1j0g</link>
      <guid>https://dev.to/findnix/building-a-search-engine-for-kids-that-doesnt-track-them-1j0g</guid>
      <description>&lt;h2&gt;
  
  
  Search engines for kids usually mean one of two things
&lt;/h2&gt;

&lt;p&gt;Either a heavily filtered wrapper around a big commercial index (which still leaks through things a filter didn't anticipate), or a walled garden with a handful of hand-picked links that goes stale in a month. &lt;a href="https://kids.findnix.eu" rel="noopener noreferrer"&gt;kids.findnix.eu&lt;/a&gt; is an attempt at a third option: build and maintain &lt;strong&gt;our own curated index&lt;/strong&gt;, specifically for kids and teenagers, as a companion project to the main &lt;a href="https://findnix.eu" rel="noopener noreferrer"&gt;findnix.eu&lt;/a&gt; search engine.&lt;/p&gt;

&lt;p&gt;The homepage states the promise plainly: &lt;em&gt;"no data is stored, and we only try to present vetted, educationally valuable content."&lt;/em&gt; Two mascots, Uli and Nele, sit on either side of the search box — which sounds like a small detail, but user testing with actual kids made clear that a friendly, recognizable interface matters as much as the filtering underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curation is a crawling problem, not a filtering problem
&lt;/h2&gt;

&lt;p&gt;Rather than filtering an existing general-purpose index after the fact, kids.findnix.eu runs its own dedicated crawlers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kids_page_image_crawler.php&lt;/code&gt; / &lt;code&gt;kids_page_video_crawler.php&lt;/code&gt; — pull images and videos specifically from sources already cleared for the kids index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crawl_sitemap_articles.php&lt;/code&gt;, &lt;code&gt;crawl_topic_pages.php&lt;/code&gt;, &lt;code&gt;crawl_wissenskarten.php&lt;/code&gt; — walk sitemaps and topic pages of vetted educational sites&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crawl_wp_generic.php&lt;/code&gt; — a generic crawler for WordPress-based educational sites, since a huge share of school/educational content in German-speaking countries runs on WordPress&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;classify_sonstiges_llm.php&lt;/code&gt; — an LLM-assisted classification pass for the long tail of content that doesn't fit a hand-written rule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result, shown live on the homepage, is a running count rather than a marketing number: pages indexed, images, videos, links. It updates because the crawlers keep running, not because someone typed a number into a template once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The search box is only half the product
&lt;/h2&gt;

&lt;p&gt;What's easy to miss from the name is that kids.findnix.eu isn't just a search box with a filter — half the site is interactive, kid-facing tools built around learning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flag Quiz&lt;/strong&gt; (&lt;code&gt;flaggenquiz.php&lt;/code&gt;) — geography, gamified&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret Code Workshop&lt;/strong&gt; (&lt;code&gt;geheimcode_werkstatt.php&lt;/code&gt;) — classic ciphers, taught by building them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Riddle Challenge&lt;/strong&gt; (&lt;code&gt;knobel_challenge.php&lt;/code&gt;) — logic puzzles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Train your own AI&lt;/strong&gt; (&lt;code&gt;ki_trainer.php&lt;/code&gt;) — a hands-on, simplified intro to how machine learning classification actually works, aimed at demystifying "AI" rather than treating it as magic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Adventure&lt;/strong&gt; (&lt;code&gt;code_abenteuer.php&lt;/code&gt;) — a first, gentle introduction to programming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these need the search index at all — they're standalone reasons to visit the site directly rather than only arriving via a search query, which matters for a product whose whole pitch is &lt;em&gt;"safe corner of the internet for kids,"&lt;/em&gt; not just &lt;em&gt;"safe search results."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility gets its own crawler, too
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;a11y_crawler.php&lt;/code&gt; checks the site's own HTML structure for accessibility issues — worth calling out specifically because it's easy for a "kids" product to over-index on colorful UI and under-index on actually being usable by kids with different needs. Running the accessibility check as its own recurring job, rather than a one-time manual pass, keeps it from silently rotting as pages get added.&lt;/p&gt;

&lt;h2&gt;
  
  
  No data stored means no data stored
&lt;/h2&gt;

&lt;p&gt;The "no data stored" line on the homepage isn't just copy — visit counting, for example, is deliberately coarse: one cookie-gated counter per visit session (30 minutes), not a log line per page view, and no data ends up tied to an individual beyond a hashed IP used only to avoid double-counting the same visit:&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_COOKIE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'kids_visited'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;kids_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"INSERT INTO fnx_kids_stats (type, referrer, url, ip_hash, lang, user_agent)
         VALUES ('visit', ?, ?, ?, ?, ?)"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nb"&gt;setcookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kids_visited'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&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;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole point of building a dedicated kids product instead of just adding a "kids mode" toggle to the main search engine: every default — logging, cookies, what gets shown, what gets stored — gets re-examined for a young audience specifically, instead of inherited from an adult product and only trimmed down after the fact.&lt;/p&gt;

&lt;p&gt;kids.findnix.eu is live at &lt;a href="https://kids.findnix.eu" rel="noopener noreferrer"&gt;kids.findnix.eu&lt;/a&gt;, EU-hosted, and — like the rest of the findnix.eu family — built to be small enough that you can actually read the whole thing and know what it does.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>findnix.eu: A GDPR-Compliant Search Engine Built From Scratch</title>
      <dc:creator>Mirko Stahnke</dc:creator>
      <pubDate>Wed, 19 Aug 2026 23:23:57 +0000</pubDate>
      <link>https://dev.to/findnix/findnixeu-a-gdpr-compliant-search-engine-built-from-scratch-39d2</link>
      <guid>https://dev.to/findnix/findnixeu-a-gdpr-compliant-search-engine-built-from-scratch-39d2</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Every major search engine today runs on the same trade: you get results, they get your data. findnix.eu is an attempt to break that trade — a search engine built in the EU, for the EU, that doesn't track you, doesn't build a profile of you, and doesn't sell what it doesn't collect in the first place.&lt;/p&gt;

&lt;p&gt;This isn't a pitch deck. It's a working search engine, built and run by one person, and this post is a quick tour of what it actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can search
&lt;/h2&gt;

&lt;p&gt;findnix.eu isn't just web search. Over time it's grown into a set of focused search verticals, each with its own index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web&lt;/strong&gt; — general search, combining multiple sources with a preference for sites that submit their own sitemap (a small way to give independent sites a fairer shot than they'd get elsewhere)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;News&lt;/strong&gt; — pulled directly from public-broadcaster media libraries across the EU (ARD, ZDF, Arte, ORF, SRF and more), with a built-in video player — no detour through the broadcaster's own site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Podcasts&lt;/strong&gt; — full show and episode search via the Podcast Index API, with direct in-browser playback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images &amp;amp; Videos&lt;/strong&gt; — licensed, freely usable media&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Science&lt;/strong&gt; — open-access papers via OpenAIRE and BASE&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firmen (Companies)&lt;/strong&gt; — a growing local business index&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kultur&lt;/strong&gt; — European cultural heritage via Europeana&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also a companion site, &lt;strong&gt;kids.findnix.eu&lt;/strong&gt;, built specifically for child-safe search — same philosophy, different audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  No tracking, stated plainly
&lt;/h2&gt;

&lt;p&gt;No tracking cookies, no IP logging, no cross-session profiling. Search history lives in your browser's local storage only, never on the server. Ads (yes, there are ads — servers cost money) are flat-rate and clearly labeled, not auction-based, and capped at 3 per page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's newest
&lt;/h2&gt;

&lt;p&gt;The latest addition is a small marketplace feature: a &lt;strong&gt;domain valuation and sale tool&lt;/strong&gt;. You get a free (unbinding, heuristic) valuation of a domain based on TLD, length, and structure, and can list it for sale for a small amount of points — findnix's internal currency, earned through site activity. Inquiries land in an in-app inbox rather than exposing your email address publicly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build this
&lt;/h2&gt;

&lt;p&gt;Mostly because the alternative — accepting that "free" search always means being the product — never sat right. findnix.eu won't out-scale Google. It doesn't try to. It's trying to be a genuinely different deal: you search, we don't remember, and the site stays funded through ads you can actually see and understand, not ones built on a profile of you.&lt;/p&gt;

&lt;p&gt;If that sounds like your kind of search engine, give it a try: &lt;a href="https://findnix.eu" rel="noopener noreferrer"&gt;findnix.eu&lt;/a&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>php</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
