<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Aumcreate]]></title><description><![CDATA[Aumcreate]]></description><link>https://aumcreate.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 10:27:03 GMT</lastBuildDate><atom:link href="https://aumcreate.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a booking flow: services, time rules, confirmation email, and what to test]]></title><description><![CDATA[The 10:00 slot gets booked twice on a Saturday and nobody typed anything wrong.]]></description><link>https://aumcreate.hashnode.dev/building-a-booking-flow-services-time-rules-confirmation-email-and-what-to-test</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/building-a-booking-flow-services-time-rules-confirmation-email-and-what-to-test</guid><category><![CDATA[Testing]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Fri, 30 Oct 2026 04:27:00 GMT</pubDate><content:encoded><![CDATA[<p>The 10:00 slot gets booked twice on a Saturday and nobody typed anything wrong. One booking came through the website at 09:58; the other was logged into the admin a minute later from a phone call. The availability screen both customers looked at had been rendered before either booking existed.</p>
<p>Booking bugs cluster in the gap between "the rule" and "the moment the rule is checked". Closing that gap is most of the work. The calendar widget is the easy part.</p>
<h2>Define each service before you touch pricing</h2>
<p>Write one row per bookable thing and fill in every field, even the ones that feel obvious:</p>
<ul>
<li>Duration in minutes as it is delivered in practice, not as advertised.</li>
<li>Buffer after (cleanup, notes, travel) and buffer before, if any.</li>
<li>Capacity: how many bookings may overlap — one chair, one room, three staff, twelve seats.</li>
<li>Resource: the person, room, or equipment the booking consumes.</li>
<li>Price, and whether a deposit is required to hold the slot.</li>
<li>Cancellation window, and what happens to the deposit inside it.</li>
<li>Location: address, meeting link, or "we come to you" with a service radius.</li>
<li>What the customer must bring or prepare.</li>
</ul>
<p>The buffer field is the one people skip and regret. A 60-minute treatment with a 15-minute turnaround is a 75-minute block on the calendar. If the system only knows about the 60, every back-to-back booking runs late by mid-afternoon and the last customer of the day pays for it.</p>
<h2>The time rules that decide whether the calendar is honest</h2>
<p>Store the business timezone once, at the account level. Render times in the visitor's timezone if you like, but every confirmation, reminder, and admin screen should print the business timezone next to the time.</p>
<p>Then the rules themselves:</p>
<ul>
<li>Opening hours per weekday, plus exceptions: holidays, one-off closures, a short day before a holiday.</li>
<li>Slot granularity. Fifteen-minute steps with a 50-minute service produce a grid where a 10:00 booking blocks 10:15, 10:30, and 10:45 as start times. Decide whether that is what you want, because customers will read it as "the 10:15 is taken".</li>
<li>Minimum notice. Can someone book in ten minutes, in two hours, next day?</li>
<li>Booking horizon. How far ahead the calendar opens — 30 days, 90, a year.</li>
<li>Per-resource availability. Two staff working Saturdays and one working Mondays is a rule, not a note in someone's head.</li>
<li>Same-day cutoff for changes, which is often different from the booking cutoff.</li>
<li>Partial-day closures, like a two-hour staff meeting that should not close the whole day.</li>
</ul>
<p>Two edge cases are worth deciding explicitly, because both generate support tickets.</p>
<p>The last slot of the day. A booking that ends exactly at closing time is valid. Systems that compute "start + duration &lt; closing" reject it, and the customer sees a calendar that stops an hour early for no visible reason.</p>
<p>Daylight saving. On the spring-forward day, 02:30 does not exist; on the fall-back day it exists twice. If times are stored as local wall-clock strings, one of those bookings is wrong and the reminder fires at the wrong hour. Store instants in UTC with the timezone as a separate field and the problem disappears.</p>
<h2>Availability has to be computed when it is asked for</h2>
<p>A slot list generated at midnight and cached for the day is wrong by 09:05. Compute availability per request from the rules, the existing bookings, and the current time.</p>
<p>The other half is holding the slot while the customer is on the payment page. A hold is a row with a status and an expiry:</p>
<pre><code>status:     pending | confirmed | cancelled | completed | no_show
starts_at:  timestamptz
expires_at: timestamptz, only set while pending
</code></pre>
<p><code>pending</code> and <code>confirmed</code> both consume the slot. <code>pending</code> expires after ten or fifteen minutes and a scheduled job clears it. Every other status releases it.</p>
<p>Then let the database refuse the double booking instead of trusting the check you just ran in application code:</p>
<pre><code class="language-sql">create unique index bookings_slot_unique
  on bookings (resource_id, starts_at)
  where status in ('pending', 'confirmed');
</code></pre>
<p>That is PostgreSQL syntax for a partial unique index. MySQL has no <code>where</code> clause on indexes, so there you either include the status in the key via a generated column or wrap the insert in a transaction that re-checks the slot before committing. Either way the second concurrent request gets an error you can turn into "that slot just went, here are the next three".</p>
<h2>The confirmation email is the product</h2>
<p>Most complaints about a booking system are complaints about the confirmation email. It needs:</p>
<ul>
<li>Date, start time, end time, and the timezone label.</li>
<li>Service name and duration, price with tax, and the deposit shown separately from the balance.</li>
<li>The address with a map link, or the meeting link — not both as an afterthought.</li>
<li>The cancellation deadline as a date and time, not as "24 hours' notice", plus the link that actually performs the cancellation.</li>
<li>A phone number or reply address that a human answers.</li>
<li>An attached calendar file.</li>
</ul>
<p>Build the attachment properly: a stable <code>UID</code> per booking, <code>DTSTAMP</code> in UTC, <code>DTSTART</code> and <code>DTEND</code> in UTC, <code>METHOD:REQUEST</code>, and a <code>SEQUENCE</code> you increment. If the customer moves the booking, resend with the same <code>UID</code> and a higher <code>SEQUENCE</code>. If it is cancelled, send <code>METHOD:CANCEL</code>. A fresh invite with a new UID leaves the old event sitting in the customer's calendar next to the new one, and they show up on the wrong day.</p>
<p>Send a reminder 24 hours out and a second one a few hours before. Both should carry the cancel link. A reminder with no exit is a reminder that produces a no-show.</p>
<p>Check the email as plain text, on a phone, in a client that blocks remote images.</p>
<h2>What to test before it goes live</h2>
<p>Work through these as scenarios, not as pages:</p>
<ul>
<li>Two browsers, same last slot, submitted at the same moment. One gets a confirmation, the other gets a clear message. No 500 and no silent overwrite.</li>
<li>The last slot of the day, booked at closing time minus the service duration. It should be accepted.</li>
<li>A booking made by someone in a different timezone. The confirmation shows the correct local time for the business.</li>
<li>Cancel exactly at the deadline, and one minute past it. Behaviour matches the policy in both directions, and the deposit path matches too.</li>
<li>Payment abandoned. The hold expires, the slot returns to the calendar, no confirmation goes out.</li>
<li>Double-tap on the submit button. One booking, one email.</li>
<li>A holiday and a half-day closure. Neither is bookable, and the day still renders without gaps that look like bugs.</li>
<li>The email in a spam check, and the <code>.ics</code> opened in two different calendar applications.</li>
<li>An admin moving a booking. The customer receives an update and the old event is replaced, not duplicated.</li>
<li>A refund or a no-show. The slot is released and the record still shows what happened and who decided it.</li>
</ul>
<p>Write the policy answers down — cancellation deadline, deposit refundability, lateness, no-show fee — and put the same text in the email and on the booking page. When the two disagree, the customer will quote the email back at you, and they will be right.</p>
]]></content:encoded></item><item><title><![CDATA[llms.txt on a WordPress site: which URLs go first, and how to keep the file current]]></title><description><![CDATA[The rewrite rule works, curl returns 200, and the access log stays empty for a month.]]></description><link>https://aumcreate.hashnode.dev/llms-txt-on-a-wordpress-site-which-urls-go-first-and-how-to-keep-the-file-current</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/llms-txt-on-a-wordpress-site-which-urls-go-first-and-how-to-keep-the-file-current</guid><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Fri, 30 Oct 2026 04:24:05 GMT</pubDate><content:encoded><![CDATA[<p>The rewrite rule works, <code>curl</code> returns 200, and the access log stays empty for a month. That is the ordinary outcome, and it is worth knowing before you spend an afternoon on it.</p>
<p>llms.txt is a plain markdown file at the site root: a title, a one-line description of the site, then grouped links with a short note on each. It was proposed in 2024 as a way for language models to read a curated map of a site instead of inferring one from HTML. No major assistant has committed to fetching it on a schedule, and there is no way to confirm that an answer engine used yours.</p>
<p>So the file is a cheap bet, not a switch. What follows is how to make the bet a good one on WordPress: the ordering, the generation, the caching, and the checks that tell you whether anything is happening at all.</p>
<h2>What it does not replace</h2>
<p>robots.txt says who may crawl. sitemap.xml lists what exists. llms.txt says what matters and why. They sit next to each other and do not conflict — but they do not cover for each other either. If your robots.txt disallows a crawler, a perfect llms.txt will not get that crawler in the door. If a page is noindex, leave it out of llms.txt too; listing it only advertises a page you have decided not to surface.</p>
<h2>Put the pages in the order a buyer would ask for them</h2>
<p>The common mistake is generating the file from a query in date order. That produces a reverse-chronological blog roll, which is the least useful ordering possible for someone trying to work out what you sell.</p>
<p>Order by the questions, not by the CMS:</p>
<ul>
<li>What this company is and what it does — homepage, and an about page only if it is short and specific.</li>
<li>What it costs and how to start — pricing, how it works, the contact or quote form.</li>
<li>The individual services or products, one line each.</li>
<li>Reference material that answers objections — FAQ, docs, a page that explains the difference between two things you offer.</li>
</ul>
<p>Leave out: category and tag archives, paginated duplicates, internal search results, staging, gated PDFs, and three-paragraph news posts. A short file with strong entries beats a complete one.</p>
<p>Each entry is one line, and the sentence after the colon is the part that earns the read:</p>
<pre><code>- [Setup service](https://example.com/setup): We install and configure the template on your host, migrate your content, and hand over with a short editing guide.
</code></pre>
<p>Write that sentence for someone who has never seen the page. Do not paste the meta description if the meta description is keyword soup.</p>
<p>One URL per line, once. If a page belongs in two groups, keep it in the earlier group — repetition costs tokens and reads as padding.</p>
<h2>Serving it from WordPress when the docroot is read-only</h2>
<p>Managed hosts often give you no writable web root, and if a CDN sits in front, a file you upload can be cached longer than you expect. The dependable path on WordPress is a rewrite rule that renders the file per request.</p>
<p>Drop this into a mu-plugin and activate nothing — files in <code>wp-content/mu-plugins/</code> load automatically:</p>
<pre><code class="language-php">&lt;?php
// wp-content/mu-plugins/llms-txt.php

add_action( 'init', function () {
    add_rewrite_rule( '^llms\.txt$', 'index.php?llms_txt=1', 'top' );
} );

add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'llms_txt';
    return $vars;
} );

/**
 * The editorial layer: page IDs, grouped, in the order they should appear.
 */
function my_llms_groups(): array {
    return [
        'Start here' =&gt; [ 12, 14 ],   // home, how it works
        'Pricing'    =&gt; [ 19 ],
        'Services'   =&gt; [ 31, 32, 33 ],
        'Answers'    =&gt; [ 44, 45 ],
    ];
}

function my_llms_summary( WP_Post $post ): string {
    $text = $post-&gt;post_excerpt !== '' ? $post-&gt;post_excerpt : $post-&gt;post_content;
    $text = wp_strip_all_tags( strip_shortcodes( $text ) );
    $text = trim( preg_replace( '/\s+/u', ' ', $text ) );

    if ( mb_strlen( $text ) &gt; 160 ) {
        $text = mb_substr( $text, 0, 157 ) . '...';
    }

    return $text;
}

function my_llms_render(): string {
    $out  = '# ' . get_bloginfo( 'name' ) . "\n\n";
    $out .= '&gt; ' . get_bloginfo( 'description' ) . "\n\n";

    foreach ( my_llms_groups() as $group =&gt; $ids ) {
        $lines = [];

        foreach ( $ids as $id ) {
            $post = get_post( $id );
            if ( ! $post || 'publish' !== $post-&gt;post_status ) {
                continue;
            }
            if ( '1' === get_post_meta( $id, '_my_llms_exclude', true ) ) {
                continue;
            }
            $lines[] = sprintf(
                '- [%s](%s): %s',
                wp_strip_all_tags( get_the_title( $post ) ),
                get_permalink( $post ),
                my_llms_summary( $post )
            );
        }

        if ( $lines ) {
            $out .= "## {$group}\n\n" . implode( "\n", $lines ) . "\n\n";
        }
    }

    return rtrim( $out ) . "\n";
}

add_action( 'template_redirect', function () {
    if ( '1' !== get_query_var( 'llms_txt' ) ) {
        return;
    }

    $cache_key = 'my_llms_txt';
    $body      = get_transient( $cache_key );

    if ( false === $body ) {
        $body = my_llms_render();
        set_transient( $cache_key, $body, HOUR_IN_SECONDS );
    }

    header( 'Content-Type: text/plain; charset=utf-8' );
    header( 'X-Robots-Tag: noindex' );
    echo $body;
    exit;
} );

add_action( 'save_post', function () {
    delete_transient( 'my_llms_txt' );
} );
</code></pre>
<p>After adding the file, open Settings → Permalinks and save once. The rewrite rule only lands after the rules are flushed; without that step <code>/llms.txt</code> keeps 404ing and <code>template_redirect</code> never sees the flag.</p>
<p>On nginx the request has to reach PHP at all:</p>
<pre><code>location / {
    try_files $uri $uri/ /index.php?$args;
}
</code></pre>
<p>Verify from outside: <code>curl -sI https://example.com/llms.txt | head -n 5</code> should show <code>content-type: text/plain</code>, and <code>curl -s https://example.com/llms.txt | head -n 20</code> should show markdown, not HTML.</p>
<p>The transient exists because rendering walks post IDs and runs content filters on every one of them. An hour is fine, and <code>save_post</code> clears the cache so an edit appears on the next request. If a CDN caches <code>.txt</code> aggressively, purge that single path on save rather than waiting for the TTL.</p>
<h2>Keeping it from drifting</h2>
<p>A file rots in two ways. Hand-written, it drifts the moment someone changes a slug. Fully automatic — every published post, newest first — it becomes a changelog nobody wants.</p>
<p>The version above sits between those: the ID list is hand-maintained, the rendering is automatic. Maintenance is a quarterly task of ten minutes. Open the email you send most often to prospects and check that every link in it appears in the file, in roughly the same order. Anything you would not put in front of a prospect does not belong in the file.</p>
<h2>llms-full.txt is usually a mistake</h2>
<p>The companion idea — dump the entire site as markdown into <code>llms-full.txt</code> — is easy to generate and expensive to consume. A template-rendered page repeats header, nav, footer, and sidebar on every URL, so a 200-page site can produce a file where most of the bytes are identical chrome.</p>
<p>If you publish one anyway:</p>
<ul>
<li>Strip the theme chrome and render the content area only.</li>
<li>Dedupe by URL, and drop pages that fail the same "would I show this to a prospect" test.</li>
<li>Reference it from the main llms.txt as a single line at the bottom, nothing more.</li>
<li>Generate it on demand rather than on a nightly cron that leaves a stale copy sitting around.</li>
</ul>
<p>Measure the file with a tokenizer before you publish it and decide what you are willing to spend. A file nobody finishes reading has no advantage over a short one.</p>
<h2>Proving whether anything fetches it</h2>
<p>Everything above is unverifiable until you look at requests. Start with the file itself:</p>
<pre><code class="language-bash">sudo zgrep -h 'llms\.txt' /var/log/nginx/access.log* \
  | awk '{print $1, $9, $12, $13, $14}' | sort | uniq -c | sort -rn | head
</code></pre>
<p>Then narrow to the agents you care about:</p>
<pre><code class="language-bash">sudo grep -iE 'GPTBot|OAI-SearchBot|ClaudeBot|Claude-User|PerplexityBot|Google-Extended' \
  /var/log/nginx/access.log | tail -n 50
</code></pre>
<p>User agents are trivially spoofed, so treat the count as a hint rather than a measurement. What carries information is the shape: a few hits from one agent, repeating weekly, is more interesting than a thousand from a scraped list. If your CDN terminates TLS, the origin log may show nothing at all and you need to read the CDN's log instead.</p>
<p>A 200 does not mean the file was read, and a read does not mean it was used. What you can check is whether the entries are correct, whether anything requests the file, and whether the pages it points to are the pages you actually want quoted.</p>
<p>If you would rather not maintain the PHP, <a href="https://aumcreate.com/plugins/aumviso?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-llms-txt-wp-generate-list-first">AumViso</a> generates llms.txt alongside schema, sitemap, and meta output. Choosing and ordering the pages stays an editorial job either way.</p>
]]></content:encoded></item><item><title><![CDATA[Publishing factory photos and video so the page still looks real without wrecking first paint on mobile]]></title><description><![CDATA[The usual fix makes it worse. The page loads slowly, so someone re-exports every photo at 800px wide with a warm filter over the lot, and now the pla…]]></description><link>https://aumcreate.hashnode.dev/publishing-factory-photos-and-video-so-the-page-still-looks-real-without-wrecking-first-paint-on-mobile</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/publishing-factory-photos-and-video-so-the-page-still-looks-real-without-wrecking-first-paint-on-mobile</guid><category><![CDATA[mobile]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Fri, 30 Oct 2026 04:13:59 GMT</pubDate><content:encoded><![CDATA[<p>The usual fix makes it worse. The page loads slowly, so someone re-exports every photo at 800px wide with a warm filter over the lot, and now the plant looks like a product render. Buyers sourcing hardware are looking for evidence that the building exists: cable trays, pallets, a person's hand on a control panel. Smooth that away and the page is fast and unconvincing.</p>
<p>The weight problem is rarely spread across forty photos. It's almost always one hero image and one autoplaying video, and the rest of the page is fine. Fix those two and leave the detail shots alone.</p>
<h2>What the phone actually downloads before it shows anything</h2>
<p>The first screen needs the HTML, the CSS, and one image — the largest element in view, usually the hero. That's the LCP element. A workable mobile budget is under roughly 500 KB for those three combined, with the hero visible in under 2.5 seconds on a mid-range Android over 4G. Everything below the first screen is a separate problem and should not be in that first request.</p>
<p>That's the whole mental model. A factory tour page with twelve photos, a poster frame and a click-to-play walkthrough can sit around 1.5–2.5 MB total and still paint fast, as long as the hero isn't a 4 MB camera JPEG.</p>
<h2>Which photos earn a place on the page</h2>
<p>A buyer is checking capability, not browsing a gallery. The set that does the work is small:</p>
<ul>
<li>the building from outside, with signage or the loading bay in frame</li>
<li>one wide shot of the floor, taken from a corner so depth reads</li>
<li>a close-up of the operation the buyer is paying for</li>
<li>the QC bench with instruments in use, not arranged</li>
<li>packing and labelling, because that's where damage claims come from</li>
<li>the nameplate or serial label of the main machine</li>
<li>raw material storage, if lead time depends on it</li>
</ul>
<p>Each one gets a caption with a specific: line number, machine model, tonnage, the month it was taken. Captions are cheap and they're what separates a real page from a stock template.</p>
<p>Cut near-duplicates without mercy. Also cut anything with a customer's part number, a competitor's logo on a box, or a whiteboard with a production schedule on it. Those are the photos that create problems later, and they cost bytes as well.</p>
<h2>Export at display size, not camera size</h2>
<p>A 6000px phone photo displayed at 760px wide is sending five times the pixels the screen can use.</p>
<ul>
<li>hero: 1600–1920px wide, one size for desktop, one at 800px for phones</li>
<li>inline photos: 1200px wide maximum</li>
<li>thumbnails and grid tiles: 400px</li>
<li>JPEG at quality 78–82, or WebP around 75, or AVIF if your encoder handles it — with a JPEG fallback</li>
</ul>
<p>Strip the metadata, which also removes GPS coordinates from photos taken at the plant:</p>
<pre><code class="language-bash">exiftool -all= -overwrite_original *.jpg
</code></pre>
<p>Keep the colour natural. If you want a consistent look, apply one mild grade to the set as a whole, not a per-image preset — uniform heavy grading is one of the fastest ways to make genuine photos read as stock.</p>
<h2>Markup that doesn't shift the layout</h2>
<p>Give the browser the dimensions and the candidate sizes, and it will pick one file and reserve the space before the image arrives.</p>
<pre><code class="language-html">&lt;picture&gt;
  &lt;source type="image/avif"
          srcset="/img/floor-800.avif 800w, /img/floor-1600.avif 1600w"
          sizes="(max-width: 720px) 100vw, 760px"&gt;
  &lt;source type="image/webp"
          srcset="/img/floor-800.webp 800w, /img/floor-1600.webp 1600w"
          sizes="(max-width: 720px) 100vw, 760px"&gt;
  &lt;img src="/img/floor-1600.jpg"
       srcset="/img/floor-800.jpg 800w, /img/floor-1600.jpg 1600w"
       sizes="(max-width: 720px) 100vw, 760px"
       width="1600" height="900"
       alt="Three machining cells on line 2, seen from the aisle"
       fetchpriority="high" decoding="async"&gt;
&lt;/picture&gt;
</code></pre>
<p>Preload that one image in the head, and only that one:</p>
<pre><code class="language-html">&lt;link rel="preload" as="image" type="image/avif"
      imagesrcset="/img/floor-800.avif 800w, /img/floor-1600.avif 1600w"
      imagesizes="(max-width: 720px) 100vw, 760px"
      href="/img/floor-1600.avif"&gt;
</code></pre>
<p>Every image below the fold gets <code>loading="lazy" decoding="async"</code> and no preload. Never lazy-load the hero — deferring the LCP element is the single most common way a fast site still measures slow.</p>
<h2>Video is where the page weight actually goes</h2>
<p>A three-minute 1080p walkthrough is 150–400 MB off a camera. Even after "compression" it's often 20 MB, and it's sitting behind the headline.</p>
<p>Split it into two assets. A six-to-ten-second silent loop near the top, around 1.5–2.5 MB at 720p, and the full walkthrough as click-to-play further down the page or on its own page. Nobody watches three minutes standing in a trade fair aisle, but plenty of people click a play button when they've already decided they're interested.</p>
<pre><code class="language-bash"># 8 second silent loop, 720p, progressive download
ffmpeg -ss 00:00:04 -t 8 -i walkthrough.mov \
  -vf "scale=1280:-2,fps=25" -an \
  -c:v libx264 -profile:v high -crf 26 -preset slow \
  -movflags +faststart line2-loop.mp4

# VP9 version for browsers that take it
ffmpeg -ss 00:00:04 -t 8 -i walkthrough.mov \
  -vf "scale=1280:-2,fps=25" -an \
  -c:v libvpx-vp9 -crf 34 -b:v 0 -row-mt 1 line2-loop.webm

# poster frame, taken from the video itself
ffmpeg -ss 00:00:05 -i walkthrough.mov -frames:v 1 \
  -vf "scale=1280:-2" line2-poster.jpg
</code></pre>
<p>Check the result with <code>ls -lh line2-loop.*</code>. If the mp4 is over 3 MB, raise the CRF before you shorten the clip.</p>
<pre><code class="language-html">&lt;video class="loop" autoplay muted loop playsinline preload="none"
       poster="/img/line2-poster.jpg" width="1280" height="720"&gt;
  &lt;source src="/video/line2-loop.webm" type="video/webm"&gt;
  &lt;source src="/video/line2-loop.mp4" type="video/mp4"&gt;
&lt;/video&gt;
</code></pre>
<p><code>preload="none"</code> plus <code>poster</code> means the poster shows immediately and the file is fetched when the browser starts playing it. If you want nothing downloaded until the visitor asks, drop <code>autoplay</code> and put a play button over the poster. On small screens, or when the visitor has asked for reduced data, show the poster only:</p>
<pre><code class="language-css">@media (max-width: 600px) {
  video.loop { display: none; }
  .loop-poster { display: block; }
}

@media (prefers-reduced-motion: reduce) {
  video.loop { display: none; }
  .loop-poster { display: block; }
}
</code></pre>
<p>Two things not to do: don't use a GIF of the machine running — the same clip as H.264 is an order of magnitude smaller; and don't put a full-screen background video behind text on mobile, where it competes with the hero image for bandwidth and destroys legibility in daylight.</p>
<h2>Serving, so the second visit is free</h2>
<p>Fingerprint the filenames (<code>floor-1600.9f2c1a.jpg</code>) and cache them permanently:</p>
<pre><code>Cache-Control: public, max-age=31536000, immutable
</code></pre>
<p>HTML stays <code>no-cache</code>. Text assets get Brotli or gzip; images and video are already compressed and shouldn't be re-encoded on the fly. If images are being served through a PHP script or a resize endpoint, move them to static files — the script alone can add hundreds of milliseconds before the first byte of the image.</p>
<p>A slider or carousel at the top of the page makes everything above harder: most implementations load every slide up front. A single static hero with a thumbnail grid underneath is easier to keep fast and easier for a buyer to scan.</p>
<h2>How to check it on the connection your buyers have</h2>
<p>Lighthouse in mobile mode with throttling, and read the LCP element and the transferred bytes rather than the score. Then do it once by hand: DevTools network throttling on Slow 4G with 4x CPU slowdown, reload, and watch when the hero appears and whether the text jumps as it does. The office Wi-Fi test tells you nothing, because your buyers are on a phone in a hall with two bars.</p>
<p>If the layout moves while loading, an image is missing <code>width</code> and <code>height</code>, or a font is swapping late. Both show up as CLS in the same report.</p>
<h2>Keeping it real while keeping it light</h2>
<p>Specifics are what make a page look genuine: a caption naming the line and the month, a person in frame, a forklift that's slightly in the way. Leave the imperfect wide shot in — it reads as a place rather than a set. Keep one wide and one close-up per process instead of fifteen angles of the same machine, and prefer a phone photo taken on the floor over a stock image of somebody else's factory, as long as it's sharp and correctly exposed.</p>
<h2>A short checklist before it goes live</h2>
<ul>
<li>hero exported at 1600–1920px, under 300 KB, AVIF or WebP with a JPEG fallback</li>
<li>only the hero is preloaded and marked <code>fetchpriority="high"</code>; everything else is lazy</li>
<li>every image has <code>width</code> and <code>height</code></li>
<li>EXIF stripped, including GPS</li>
<li>the top video is a silent loop under about 2.5 MB with a poster frame from the clip</li>
<li>the full walkthrough is click-to-play, not autoplaying</li>
<li>no GIFs</li>
<li>image filenames are hashed and cached for a year</li>
<li>tested once on a real mid-range Android over mobile data</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Form submissions into a CRM without losing the ones that fail]]></title><description><![CDATA[The thank-you message renders, the form clears, and the lead is gone.]]></description><link>https://aumcreate.hashnode.dev/form-submissions-into-a-crm-without-losing-the-ones-that-fail</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/form-submissions-into-a-crm-without-losing-the-ones-that-fail</guid><category><![CDATA[Reliability]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Fri, 30 Oct 2026 04:03:47 GMT</pubDate><content:encoded><![CDATA[<p>The thank-you message renders, the form clears, and the lead is gone. Somewhere between the submit handler and the CRM record there was a timeout, a 200 response carrying an error inside the body, or a field the CRM rejected, and nothing in the flow was built to notice any of it.</p>
<p>The fix is not a better webhook call. It is accepting that the submission is saved the moment it arrives, and that delivery to the CRM is a separate job that can fail, retry, and be replayed without the visitor seeing a thing.</p>
<h2>Save first, send later</h2>
<p>The submit handler has one job: validate, write a row, answer the visitor. No outbound HTTP inside the request someone is waiting on.</p>
<pre><code class="language-php">add_action('wp_ajax_nopriv_lead_submit', 'handle_lead_submit');
add_action('wp_ajax_lead_submit', 'handle_lead_submit');

function handle_lead_submit() {
    check_ajax_referer('lead_form');

    $name  = sanitize_text_field($_POST['name'] ?? '');
    $email = sanitize_email($_POST['email'] ?? '');
    $phone = sanitize_text_field($_POST['phone'] ?? '');
    $note  = sanitize_textarea_field($_POST['message'] ?? '');

    if ($name === '' || !is_email($email)) {
        wp_send_json_error(['message' =&gt; 'Name and a valid email are required'], 422);
    }

    global $wpdb;
    $table = $wpdb-&gt;prefix . 'lead_queue';

    $wpdb-&gt;insert($table, [
        'idem_key'        =&gt; wp_generate_uuid4(),
        'payload'         =&gt; wp_json_encode([
            'name'    =&gt; $name,
            'email'   =&gt; $email,
            'phone'   =&gt; $phone,
            'message' =&gt; $note,
            'source'  =&gt; 'contact-form',
            'sent_at' =&gt; gmdate('c'),
        ]),
        'status'          =&gt; 'pending',
        'attempts'        =&gt; 0,
        'next_attempt_at' =&gt; gmdate('Y-m-d H:i:s'),
    ], ['%s', '%s', '%s', '%d', '%s']);

    wp_send_json_success(['reference' =&gt; $wpdb-&gt;insert_id]);
}
</code></pre>
<p>The table it writes into:</p>
<pre><code class="language-sql">CREATE TABLE wp_lead_queue (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  idem_key CHAR(36) NOT NULL,
  payload LONGTEXT NOT NULL,
  status ENUM('pending','sent','dead') NOT NULL DEFAULT 'pending',
  attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
  last_error VARCHAR(500) NULL,
  next_attempt_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  sent_at DATETIME NULL,
  PRIMARY KEY (id),
  UNIQUE KEY idem_key (idem_key),
  KEY status_next (status, next_attempt_at)
);
</code></pre>
<p>The unique key on <code>idem_key</code> earns its place. A double-submit inserts twice only when the key differs, and every retry of the same row carries the same key to the CRM, which gives the receiving side something to deduplicate on.</p>
<h2>What counts as failed</h2>
<p>Three outcomes get logged as success by accident:</p>
<ul>
<li>The request threw. <code>wp_remote_post</code> returns a <code>WP_Error</code> on timeout or DNS failure, and plenty of handlers never look.</li>
<li>The status code was 4xx or 5xx. Retrying a 400 with an identical body will keep failing; that row needs a human, not another attempt.</li>
<li>The status was 200 and the body says <code>{"ok": false}</code>. APIs report field-level rejections this way all the time.</li>
</ul>
<p>Write the rules down and code against them: 2xx plus a body that does not declare an error means delivered. 4xx other than 429 means dead, no retry. 429, 5xx and transport errors mean retry with backoff.</p>
<h2>The worker</h2>
<pre><code class="language-php">function aum_push_leads() {
    global $wpdb;
    $table = $wpdb-&gt;prefix . 'lead_queue';

    $rows = $wpdb-&gt;get_results(
        "SELECT * FROM {$table}
          WHERE status = 'pending'
            AND next_attempt_at &lt;= UTC_TIMESTAMP()
          ORDER BY id ASC
          LIMIT 20"
    );

    foreach ($rows as $row) {
        $res = wp_remote_post('https://crm.example.com/api/leads', [
            'timeout' =&gt; 8,
            'headers' =&gt; [
                'Content-Type'    =&gt; 'application/json',
                'Idempotency-Key' =&gt; $row-&gt;idem_key,
            ],
            'body'    =&gt; $row-&gt;payload,
        ]);

        if (is_wp_error($res)) {
            aum_lead_retry($row, 'transport: ' . $res-&gt;get_error_message());
            continue;
        }

        $code = wp_remote_retrieve_response_code($res);
        $body = wp_remote_retrieve_body($res);

        if ($code &gt;= 200 &amp;&amp; $code &lt; 300 &amp;&amp; !aum_lead_body_is_error($body)) {
            $wpdb-&gt;update($table, [
                'status'  =&gt; 'sent',
                'sent_at' =&gt; gmdate('Y-m-d H:i:s'),
            ], ['id' =&gt; $row-&gt;id]);
            continue;
        }

        if ($code &gt;= 400 &amp;&amp; $code &lt; 500 &amp;&amp; $code !== 429) {
            $wpdb-&gt;update($table, [
                'status'     =&gt; 'dead',
                'attempts'   =&gt; $row-&gt;attempts + 1,
                'last_error' =&gt; substr($code . ' ' . $body, 0, 500),
            ], ['id' =&gt; $row-&gt;id]);
            continue;
        }

        aum_lead_retry($row, $code . ' ' . $body);
    }
}

function aum_lead_body_is_error($body) {
    $data = json_decode($body, true);
    return is_array($data) &amp;&amp; array_key_exists('ok', $data) &amp;&amp; $data['ok'] === false;
}

function aum_lead_retry($row, $error) {
    global $wpdb;
    $attempts = $row-&gt;attempts + 1;
    $delay    = min(3600, 30 * (2 ** $attempts)) + wp_rand(0, 30);

    $wpdb-&gt;update($wpdb-&gt;prefix . 'lead_queue', [
        'attempts'        =&gt; $attempts,
        'status'          =&gt; $attempts &gt;= 8 ? 'dead' : 'pending',
        'last_error'      =&gt; substr($error, 0, 500),
        'next_attempt_at' =&gt; gmdate('Y-m-d H:i:s', time() + $delay),
    ], ['id' =&gt; $row-&gt;id]);
}
</code></pre>
<p>The jitter matters when a CRM has a bad minute. Twenty queued rows that all retry on the same second will all fail again on the same second.</p>
<p>A worker running every five minutes with a cap of twenty rows handles 200 leads an hour and gives a row eight attempts over roughly three hours before it lands in <code>dead</code>. If volume grows, raise the cap rather than shortening the interval.</p>
<pre><code class="language-php">add_filter('cron_schedules', function ($schedules) {
    $schedules['five_minutes'] = ['interval' =&gt; 300, 'display' =&gt; 'Every five minutes'];
    return $schedules;
});

add_action('init', function () {
    if (!wp_next_scheduled('aum_push_leads_hook')) {
        wp_schedule_event(time() + 300, 'five_minutes', 'aum_push_leads_hook');
    }
});
add_action('aum_push_leads_hook', 'aum_push_leads');
</code></pre>
<p>Traffic-driven cron skips runs on a quiet site, which is exactly when a lead from last night is sitting in the queue. If leads need to land within minutes, point system cron at <code>wp cron event run --due-now</code> every five minutes and keep both.</p>
<h2>Duplicates</h2>
<p>A retry after a timeout is the classic duplicate: the CRM saved the record, the response never came back, the queue tries again. Both defences are worth having.</p>
<p>Send an idempotency key on every attempt — same key for every retry of the same row. The CRM stores it and returns the original record instead of creating a second. Not every CRM honours the header, so keep a fallback: hash the normalised email plus phone and check for an existing record before insert on the receiving side.</p>
<p>Write the CRM's record id back into the row when it comes back. When someone asks whether a lead arrived, the answer becomes a lookup instead of a search through two systems.</p>
<h2>Replaying the dead queue</h2>
<p>Dead rows still hold a payload and a decision. Read the errors before touching anything:</p>
<pre><code class="language-bash">wp db query "SELECT id, attempts, last_error, created_at FROM wp_lead_queue WHERE status='dead' ORDER BY id DESC LIMIT 50"
</code></pre>
<p>If the cause was a mapping change or an expired token, fix that first, then requeue:</p>
<pre><code class="language-bash">wp db query "UPDATE wp_lead_queue SET status='pending', attempts=0, next_attempt_at=UTC_TIMESTAMP() WHERE status='dead'"
</code></pre>
<p>Anything still failing after the second pass is a conversation with whoever owns the CRM, not another retry loop.</p>
<p>Alert on the dead count. A daily email with the number and the top three errors is enough. A queue nobody watches is decoration.</p>
<h2>Inbound webhooks deserve the same care</h2>
<p>If the CRM calls back — status change, assignment, a note added — verify the signature against the raw body before parsing:</p>
<pre><code class="language-php">$raw  = file_get_contents('php://input');
$sig  = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$want = hash_hmac('sha256', $raw, CRM_WEBHOOK_SECRET);

if (!hash_equals($want, $sig)) {
    status_header(401);
    exit;
}
</code></pre>
<p>Sign the raw bytes, not a re-encoded copy of the decoded array; key order and whitespace shift and the signature will never match. Store the event id and ignore repeats so a redelivered callback does not apply twice.</p>
<h2>Field mapping is where leads quietly die</h2>
<p>A field the CRM treats as required and your form treats as optional is a 400 on every submission that leaves it blank. Write the mapping down once, with type and required flag:</p>
<table>
<thead>
<tr>
<th>Form field</th>
<th>CRM field</th>
<th>Type</th>
<th>Required</th>
<th>If missing</th>
</tr>
</thead>
<tbody><tr>
<td>name</td>
<td>last_name</td>
<td>text</td>
<td>yes</td>
<td>form blocks submit</td>
</tr>
<tr>
<td>email</td>
<td>email</td>
<td>email</td>
<td>yes</td>
<td>form blocks submit</td>
</tr>
<tr>
<td>phone</td>
<td>phone</td>
<td>E.164</td>
<td>no</td>
<td>send null, not an empty string</td>
</tr>
<tr>
<td>message</td>
<td>description</td>
<td>text, 2000 chars</td>
<td>no</td>
<td>truncate</td>
</tr>
<tr>
<td>source</td>
<td>lead_source</td>
<td>picklist</td>
<td>yes</td>
<td>default value</td>
</tr>
</tbody></table>
<p>Send the internal value for a picklist, not the label, and check the list before adding an option to the form. Empty string and null are not the same thing to every API — pick one and stay consistent.</p>
<h2>What to check on a setup that already exists</h2>
<p>Submit a test entry with the CRM unreachable and see whether a row lands in the queue. Submit the same entry twice and count the records. Find a row in <code>dead</code> and ask whether anyone was told. Read a week of <code>last_error</code> values — expired tokens, a renamed picklist value, a rate limit nobody raised all show up there as patterns.</p>
<p>The visitor's side of all this is one line of text. Everything that keeps that line honest happens after they close the tab.</p>
<p>If you are building this on WordPress and would rather not maintain the queue and the replay yourself, that is the kind of work we take on: <a href="https://aumcreate.com/services?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-form-webhook-crm">custom development</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Blocking one AI crawler and letting the rest in: what robots.txt and server rules each actually control]]></title><description><![CDATA[A robots.txt line looks like a switch and behaves like a note taped to the door. Your web server config is the lock.]]></description><link>https://aumcreate.hashnode.dev/blocking-one-ai-crawler-and-letting-the-rest-in-what-robots-txt-and-server-rules-each-actually-control</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/blocking-one-ai-crawler-and-letting-the-rest-in-what-robots-txt-and-server-rules-each-actually-control</guid><category><![CDATA[web crawling]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Fri, 30 Oct 2026 03:59:34 GMT</pubDate><content:encoded><![CDATA[<p>A robots.txt line looks like a switch and behaves like a note taped to the door. Your web server config is the lock. If the plan is "one AI crawler out, everyone else keeps working", you need to know which of the two layers you are relying on for each crawler, because they cover different things and fail in different ways.</p>
<h2>What a robots.txt group actually applies to</h2>
<p>A crawler fetches the file, matches its own product token against the <code>User-agent</code> lines, and picks one group: the most specific match it can find. Nothing else in the file applies to it, and the order the groups appear in changes nothing.</p>
<p>Two things follow from that, and both surprise people the first time.</p>
<p>A crawler that is not named anywhere falls back to the <code>*</code> group. If the file has no <code>*</code> group at all, that crawler is allowed everywhere. Being absent from the file is not a block.</p>
<p>A crawler that is named exactly gets that group and nothing else. Write a <code>User-agent: X</code> group containing <code>Allow: /</code> and the <code>*</code> group's <code>Disallow: /admin/</code> no longer reaches X. The named group wins outright, whatever the fallback says.</p>
<p>So the shape for "turn one away, leave the others on their normal terms" is one narrow group plus the fallback that describes everyday access:</p>
<pre><code># normal rules for everyone
User-agent: *
Disallow: /admin/
Disallow: /search
Disallow: /*?s=

# one crawler, fully out
User-agent: GPTBot
Disallow: /
</code></pre>
<p><code>Allow</code> inside the named group is only needed when a <code>Disallow</code> in that same group would otherwise catch the path — <code>Disallow: /</code> plus <code>Allow: /blog/</code> keeps the blog open for that crawler. Wildcard and end-anchor matching follows Google's documented syntax; other crawlers implement a subset, so keep expressions simple if the rule has to hold across all of them.</p>
<p>The token matters more than the file. Operators publish separate tokens for separate jobs — a training crawler, a search/answer crawler, a fetcher triggered by a user — and blocking one does not block the others. Google-Extended is not Googlebot: one governs use of your content in Gemini training and grounding, the other governs Search. Block the training token and leave the search token alone, and the page stays in search results. Check the operator's current documentation for the exact token you mean, because names get added and renamed.</p>
<h2>Where robots.txt stops</h2>
<p>It is a request. A crawler that ignores it loses nothing.</p>
<p>The <code>User-agent</code> line is a string the client writes about itself. Anything can send <code>GPTBot</code>.</p>
<p>No rule applies backwards. Pages already fetched, and anything already in a dataset, stay where they are. A new <code>Disallow</code> is not a deletion.</p>
<h2>Blocking the token at the server</h2>
<p>nginx, mapping the header to a flag and returning 403:</p>
<pre><code class="language-nginx">map $http_user_agent $ai_block {
    default   0;
    ~*GPTBot  1;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    if ($ai_block) {
        return 403;
    }

    # the rest of the site
}
</code></pre>
<p>An <code>if</code> wrapping a single <code>return</code> is one of the uses nginx documents as safe. Keep the map at <code>http</code> level and add a line per token — one map, many rules, no repeated <code>if</code> blocks to keep in sync.</p>
<p>Apache, with an environment flag:</p>
<pre><code class="language-apache">SetEnvIfNoCase User-Agent "GPTBot" ai_block=1

&lt;RequireAll&gt;
    Require all granted
    Require not env ai_block
&lt;/RequireAll&gt;
</code></pre>
<p>Return 403 rather than a 200 with an empty body. A 200 tells the crawler the fetch succeeded and hides the block from your own logs.</p>
<p>Both layers key off the same string and inherit the same weakness: the token can be forged. Server-side blocking raises the effort, not the certainty. It also matches substrings — <code>GPTBot</code> will hit a browser extension that happens to contain the word — so keep the patterns tight and read the 403 log before assuming every hit was the crawler.</p>
<h2>Checking that a request is really from the operator</h2>
<p>If you would rather allowlist than blocklist, verify the IP instead of trusting the header. Forward-confirmed reverse DNS is the cheap version: look up the PTR record, resolve the hostname forward, and require it to return the original address.</p>
<pre><code class="language-python">import socket, sys

def fcrdns(ip):
    try:
        host, _, _ = socket.gethostbyaddr(ip)
    except (socket.herror, socket.gaierror):
        return None
    try:
        infos = socket.getaddrinfo(host, None)
    except socket.gaierror:
        return None
    forward = {i[4][0] for i in infos}
    return host if ip in forward else None

for ip in sys.argv[1:]:
    print(ip, fcrdns(ip))
</code></pre>
<p>The hostname suffix you accept has to come from the operator's published documentation; a PTR record pointing at a domain you do not recognise should be rejected. Forward confirmation is the step that stops someone pointing their own PTR at a trusted domain and walking through the allowlist.</p>
<p>Worth the trouble only if the rule is allowlist-shaped. For a blocklist, the user-agent map is enough.</p>
<h2>An order that saves rework</h2>
<p>Decide which token you are blocking, and whether the search token is separate from it. Getting that wrong is the mistake that takes longest to notice.</p>
<p>Add the named group to robots.txt and keep the <code>*</code> group describing everyday access. Honest crawlers honour the file without any server rule, and it documents intent for whoever reads the config next.</p>
<p>Watch the access log before you enforce. You want to know which tokens actually arrive and how often — the AumCrawl plugin tabulates AI crawler hits per bot and lets you allow or block them one at a time, which is quicker than grepping a month of logs. <a href="https://aumcreate.com/crawlers?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-block-single-ai-bot">AumCrawl</a></p>
<p>Then add the server rule and check it with a forged request:</p>
<pre><code class="language-bash">curl -sS -o /dev/null -w '%{http_code}\n' -A 'GPTBot/1.1' https://example.com/
</code></pre>
<p>You should see 403 there, and 200 from a normal browser request. Keep the robots.txt rule after the server rule goes live — removing it means the next person to open the file has no idea the block was deliberate.</p>
<p>The blocking is the easy half. Content that was already fetched is the half no config file can undo.</p>
]]></content:encoded></item><item><title><![CDATA[Backups You Can Prove, and a Migration Window You Can Hold]]></title><description><![CDATA[Two failures tend to arrive together. A backup that was never restored turns out to be missing the uploads directory, and the migration window was es…]]></description><link>https://aumcreate.hashnode.dev/backups-you-can-prove-and-a-migration-window-you-can-hold</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/backups-you-can-prove-and-a-migration-window-you-can-hold</guid><category><![CDATA[Devops]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Thu, 29 Oct 2026 07:13:21 GMT</pubDate><content:encoded><![CDATA[<p>Two failures tend to arrive together. A backup that was never restored turns out to be missing the uploads directory, and the migration window was estimated from the size of the archive rather than from a rehearsal. Both are cheap to prevent and expensive to discover at 23:40 on a Friday.</p>
<p>The order below is the point: prove the backup first, measure the restore, and only then book the window.</p>
<h2>What "the site" actually includes</h2>
<p>Most backup posts stop at the database and <code>wp-content</code>. That archive restores a site that looks fine and quietly loses money.</p>
<ul>
<li>the database</li>
<li><code>wp-content</code>: uploads, themes, plugins, mu-plugins, and any drop-ins such as object cache or advanced cache files</li>
<li><code>wp-config.php</code>, including salts and credentials</li>
<li>server-level configuration: cron entries, upload and memory limits the application depends on, redirect rules at the proxy or CDN</li>
<li>TLS certificate issuance and renewal</li>
<li>DNS records beyond the A record: MX, SPF, DKIM, DMARC, and any verification TXT records</li>
<li>everything registered elsewhere that points at your domain: payment callbacks, webhook endpoints, OAuth redirect URIs, IP allowlists</li>
</ul>
<p>That last group is the one that bites. A restore can be perfect and payments still fail, because the provider is still calling the old host.</p>
<h2>Frequency is a decision, not a default</h2>
<ul>
<li>database: daily at minimum, plus one immediately before any plugin or theme update and before any content import</li>
<li>files: on a schedule matched to how often they change; uploads move constantly on a shop and rarely on a brochure site</li>
<li>configuration: on change, and in version control</li>
<li>retention: a few days locally, several weeks off the host</li>
<li>at least one copy that a compromised admin account cannot delete, with its own credentials and its own access path</li>
</ul>
<p>The nightly database export, with the day-of-week as the filename so rotation is trivial:</p>
<pre><code>17 2 * * * /usr/local/bin/wp --path=/var/www/site db export /var/backups/db-$(date +\%u).sql --quiet
</code></pre>
<p>Note the escaped <code>%</code> — cron treats an unescaped one as a newline and the job fails silently.</p>
<p>Files, plus a checksum you can verify after the copy leaves the machine:</p>
<pre><code class="language-bash">STAMP=$(date +%Y%m%d-%H%M)
tar -czf /var/backups/files-$STAMP.tar.gz -C /var/www/site wp-content wp-config.php
sha256sum /var/backups/*$STAMP* &gt; /var/backups/$STAMP.sha256
chmod 600 /var/backups/*
</code></pre>
<p>Encrypt before it goes to shared storage. <code>wp-config.php</code> and the users table travel in that archive, so an unencrypted backup in a bucket is a credential leak with a delay fuse.</p>
<h2>The drill: restore somewhere disposable</h2>
<p>The restore is the artefact. The archive is only a hypothesis until you have proven it.</p>
<pre><code class="language-bash">wp db create
wp db import /var/backups/db-20261026-0200.sql
wp db check

wp search-replace 'https://www.example.com' 'https://drill.example.net' \
  --all-tables --precise --dry-run
</code></pre>
<p>Read the dry run before running it for real. It reports the number of replacements per table. A zero on a table you know contains the domain means the replace is being skipped, not that the table is clean.</p>
<pre><code class="language-bash">wp search-replace 'https://www.example.com' 'https://drill.example.net' \
  --all-tables --precise
wp cache flush
</code></pre>
<p><code>--precise</code> exists because serialised values store their own string lengths. A plain <code>sed</code> or <code>UPDATE ... REPLACE()</code> changes the URL without updating the length prefix and the value becomes unreadable — you find out when a widget renders empty and the settings page refuses to save.</p>
<p>Compare counts between source and drill rather than trusting the import message:</p>
<pre><code class="language-bash">wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_status='publish';"
wp db query "SELECT COUNT(*) FROM wp_postmeta;"
find wp-content/uploads -type f | wc -l
</code></pre>
<p>Then walk the checklist on the restored copy, writing down the wall-clock time next to each item:</p>
<ul>
<li><input disabled type="checkbox" /> home page and one deep page return 200</li>
<li><input disabled type="checkbox" /> log in as an administrator and as a non-administrator user</li>
<li><input disabled type="checkbox" /> an uploaded image renders at full size and as a generated thumbnail (the thumbnail proves the metadata survived, not just the file)</li>
<li><input disabled type="checkbox" /> the contact form submits and the submission arrives where it should</li>
<li><input disabled type="checkbox" /> the scheduled task fires</li>
<li><input disabled type="checkbox" /> outbound mail sends from the right domain and passes SPF and DKIM at the receiving side</li>
<li><input disabled type="checkbox" /> the redirects that matter still work: http to https, non-www to www, old URLs</li>
<li><input disabled type="checkbox" /> site search returns results (a restore often needs its index rebuilt)</li>
<li><input disabled type="checkbox" /> one payment or webhook callback in the provider's sandbox mode</li>
</ul>
<p>The total at the bottom is your restore time. It is the number the window is built from — not the size of the tarball, and not a guess.</p>
<h2>Turning the drill into a window</h2>
<pre><code>window = drill time × 1.5 + DNS TTL + verification + rollback budget
</code></pre>
<p>The 1.5 accounts for doing it while someone is waiting on you. Lower the DNS TTL to 300 seconds at least one old-TTL period before the window so a rollback flip is fast:</p>
<pre><code class="language-bash">dig +noall +answer www.example.com A
</code></pre>
<p>If the arithmetic does not fit what the business can tolerate, do not shorten the verification block. Rehearse again, or split the move: copy files ahead of time, change the database the night of.</p>
<h2>The order on the night</h2>
<p>The order matters more than the speed.</p>
<ol>
<li>Announce the freeze and stop content edits. Someone will be halfway through a page; tell them before, not during.</li>
<li>Take the final export while nothing is writing: database and files from the same moment, or you will spend the window reconciling a mismatch.</li>
<li>Import on the target, run the search-replace, fix ownership and permissions.</li>
<li>Verify against the target using a hosts-file entry or a temporary hostname, before DNS moves, so a failure never reaches visitors.</li>
<li>Flip DNS.</li>
<li>Verify again over the public name — the same checklist, because a hostname that resolves is not a site that works.</li>
<li>Leave the old host untouched and serving. Do not delete anything yet.</li>
</ol>
<p>Step 4 looks like this on your own machine:</p>
<pre><code>203.0.113.10  www.example.com
</code></pre>
<p>That address is a placeholder; put the target server's real one there, flush the local resolver cache, and test with the production hostname so URL generation, cookies and redirects behave exactly as they will after the flip.</p>
<p>Write the rollback triggers down before you start: checkout failing, logins failing, or an error rate above a threshold you name in advance. And note that anything created on the new host after cutover is lost if you flip back — so nobody edits content until verification passes.</p>
<h2>After the window</h2>
<ul>
<li>keep the old host's data for at least one full backup cycle of the new host</li>
<li>confirm the new host's own backup job ran, and verify the checksum of the copy that landed offsite</li>
<li>update everything that pointed at the old location: provider callbacks, uptime monitoring, external cron, allowlists</li>
<li>watch the error log and the mail queue for the first day, not the first hour</li>
</ul>
<p>A migration is finished when the new host has produced a backup you have restored once. Until then you have moved a site and inherited the same unproven archive you started with.</p>
<hr />
<p>A note on where this comes from: AumCreate deploys WordPress sites onto client-owned hosting — that is the <a href="https://aumcreate.com/setup?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-backup-proof-and-migration-window">Setup service</a> — and this drill is the handover step that makes a go-live date predictable. If you are moving an existing site rather than starting one, the checklist above applies as written.</p>
]]></content:encoded></item><item><title><![CDATA[How to configure your sitemap and page meta so AI search can find and read your pages]]></title><description><![CDATA[A site goes live. Search indexes it within a day. Then someone asks an assistant who does this kind of work in their city, and the answer either miss…]]></description><link>https://aumcreate.hashnode.dev/how-to-configure-your-sitemap-and-page-meta-so-ai-search-can-find-and-read-your-pages</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/how-to-configure-your-sitemap-and-page-meta-so-ai-search-can-find-and-read-your-pages</guid><category><![CDATA[sitemap]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Thu, 29 Oct 2026 06:35:54 GMT</pubDate><content:encoded><![CDATA[<p>A site goes live. Search indexes it within a day. Then someone asks an assistant who does this kind of work in their city, and the answer either misses the site or describes it with a sentence lifted from the footer.</p>
<p>Two separate jobs got skipped. Sitemaps and robots rules decide whether a crawler can reach a URL and how often it comes back. Meta, headings and structured data decide what it takes away once it arrives. Plenty of sites do one and not the other.</p>
<h2>Reaching the page</h2>
<p>A single allow-all group covers every crawler that doesn't have its own group:</p>
<pre><code>User-agent: *
Allow: /
Disallow: /cart
Disallow: /checkout

Sitemap: https://example.com/sitemap.xml
</code></pre>
<p>Per-bot groups are worth writing only when the rules differ. <code>User-agent: GPTBot</code> followed by <code>Allow: /</code> changes nothing when <code>*</code> already allows everything — it just leaves a file that is harder to audit six months later. Add groups when you block one crawler and let another through.</p>
<p>Two failures in this area cause more missing pages than any block: a CDN or WAF rule that rejects unfamiliar user agents before robots.txt is fetched at all, and a staging <code>Disallow: /</code> that survives the go-live.</p>
<h2>Sitemap: a URL list with a date</h2>
<p><code>priority</code> and <code>changefreq</code> are ignored by every consumer that matters. What is left is <code>loc</code> and <code>lastmod</code>, and <code>lastmod</code> only helps while it stays honest.</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&gt;
  &lt;url&gt;
    &lt;loc&gt;https://example.com/setup&lt;/loc&gt;
    &lt;lastmod&gt;2026-10-14&lt;/lastmod&gt;
  &lt;/url&gt;
  &lt;url&gt;
    &lt;loc&gt;https://example.com/work/home-party-hibachi&lt;/loc&gt;
    &lt;lastmod&gt;2026-09-30&lt;/lastmod&gt;
  &lt;/url&gt;
&lt;/urlset&gt;
</code></pre>
<p>Constraints worth enforcing:</p>
<ul>
<li>absolute URLs, same host as the canonical tag</li>
<li>no 301s, no 404s, no <code>noindex</code> targets — an entry pointing at a noindex page is a contradiction and burns crawl budget</li>
<li>beyond 50,000 URLs or 50MB, split by section and list the parts in a sitemap index</li>
<li>separate sitemaps for pages and for images or products; don't mix types in one file</li>
</ul>
<p>WordPress serves one at <code>/wp-sitemap.xml</code>. Two changes are usually worth making. Drop the users provider, which publishes author archives you probably don't want indexed:</p>
<pre><code class="language-php">add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
    if ( 'users' === $name ) {
        return false;
    }
    return $provider;
}, 10, 2 );
</code></pre>
<p>And keep search results and 404s out of the index:</p>
<pre><code class="language-php">add_filter( 'wp_robots', function ( array $robots ) {
    if ( is_search() || is_404() ) {
        $robots['noindex'] = true;
        $robots['follow']  = true;
    }
    return $robots;
} );
</code></pre>
<h2>Check that every entry returns 200</h2>
<p>Reading a sitemap by eye stops working at about ten URLs. Loop it:</p>
<pre><code class="language-bash">curl -s https://example.com/sitemap.xml \
  | grep -o '&lt;loc&gt;[^&lt;]*&lt;/loc&gt;' \
  | sed 's/&lt;[^&gt;]*&gt;//g' \
  | while read -r u; do
      printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"
    done
</code></pre>
<p>Anything that isn't 200 gets fixed or removed. So does a URL that returns 200 but canonicalises elsewhere — the sitemap should hold final URLs only.</p>
<h2>Page meta: the frame the summary sits in</h2>
<p>Assistants summarise visible text far more than meta tags, but the title and description still set the frame. Generic values produce a generic summary, and the model fills the gaps itself.</p>
<pre><code class="language-html">&lt;title&gt;WordPress setup: your host, your content, live in 5 working days&lt;/title&gt;
&lt;meta name="description" content="We install the template on your own hosting, replace every page with your content, and hand over a one-page editing guide. Live 5 working days after materials arrive."&gt;
&lt;link rel="canonical" href="https://example.com/setup"&gt;
</code></pre>
<p>A description written as a claim plus specifics survives paraphrasing. "We provide professional web solutions" gives a parser nothing to work with.</p>
<p>Also on every page worth quoting:</p>
<ul>
<li>one <code>&lt;h1&gt;</code> describing the page's job, not the site name</li>
<li>a first paragraph that states the subject without throat-clearing</li>
<li><code>og:title</code> and <code>og:description</code> mirroring the title and description, since chat products and link previews read those</li>
</ul>
<h2>Structured data that answers what this is</h2>
<p>JSON-LD is the block most likely to be quoted verbatim. Keep it to types that describe the page accurately; an inflated <code>Service</code> block does more damage than no block at all.</p>
<pre><code class="language-html">&lt;script type="application/ld+json"&gt;
{
  "@context": "https://schema.org",
  "@type": "Service",
  "name": "WordPress template setup",
  "serviceType": "Website setup",
  "provider": {
    "@type": "Organization",
    "name": "Example Pte Ltd",
    "url": "https://example.com/"
  },
  "areaServed": "SG",
  "offers": {
    "@type": "Offer",
    "price": "399",
    "priceCurrency": "USD",
    "url": "https://example.com/setup"
  }
}
&lt;/script&gt;
</code></pre>
<p>Every value here should also appear in the body. Parsers cross-check against the rendered page; a price in schema that the page never states is the quickest way to get the whole block discounted.</p>
<h2>A plain-text map</h2>
<p><code>llms.txt</code> at the site root is a Markdown file listing the pages worth reading, one line each with a link and a description:</p>
<pre><code># Example Pte Ltd

&gt; WordPress site setup on the client's own hosting, based in Singapore.

## Pages
- [Setup](https://example.com/setup): what is included, what the client supplies, timeline
- [Work](https://example.com/work): delivered projects with links
- [Templates](https://example.com/templates/corporate): demo sites, not client projects
</code></pre>
<p>Keep the descriptions factual. It is a table of contents, not a pitch.</p>
<p>Maintaining sitemap, meta, schema and <code>llms.txt</code> as four separate things is where most of this rots. <a href="https://aumcreate.com/plugins/aumviso?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-sitemap-meta-ai-readable">AumViso</a> covers all four from one screen on WordPress, if that is the stack you are on.</p>
<h2>What the crawler sees, versus what the browser shows</h2>
<p>This sits behind most reports of an assistant being unable to read a site. The browser executes JavaScript; a crawler may not.</p>
<pre><code class="language-bash">curl -s -A GPTBot https://example.com/setup | grep -o '&lt;h1[^&gt;]*&gt;[^&lt;]*&lt;/h1&gt;'
</code></pre>
<p>An empty result means the main text is client-rendered and the parser receives a shell. The usual cause is a builder that hydrates everything from JSON. Server-render or pre-render at minimum the headings, paragraphs, prices and addresses.</p>
<p>Two quieter failures:</p>
<ul>
<li>paragraphs baked into images. Alt text serves screen readers; it does not carry a paragraph into a summary.</li>
<li>content behind a cookie wall, a login, or an initial-HTML loading placeholder that never resolves for a non-JS fetch.</li>
</ul>
<h2>Order to do it in</h2>
<p>Robots and sitemap first — no other fix matters if the URL can't be reached. Then canonical consistency and the 200 check. Then titles, descriptions and headings on the pages you want quoted. Then JSON-LD, then <code>llms.txt</code>. None of it needs a rebuild; the checks are the part people skip, and they are worth re-running after every deploy.</p>
]]></content:encoded></item><item><title><![CDATA[Which AI crawlers are hitting your site, and which ones are lying about it]]></title><description><![CDATA[A user agent string is a self-declaration. When the log shows the token of a well-known crawler, the only fact you have is that something chose to se…]]></description><link>https://aumcreate.hashnode.dev/which-ai-crawlers-are-hitting-your-site-and-which-ones-are-lying-about-it</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/which-ai-crawlers-are-hitting-your-site-and-which-ones-are-lying-about-it</guid><category><![CDATA[Crawlers]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Thu, 29 Oct 2026 06:20:00 GMT</pubDate><content:encoded><![CDATA[<p>A user agent string is a self-declaration. When the log shows the token of a well-known crawler, the only fact you have is that something chose to send those bytes. On a typical WordPress site the same claimed token arrives from the crawler's own range, from a cloud host, and from a residential connection, sometimes inside the same hour.</p>
<p>What follows turns that into a decision you can defend: get the string into the log, count it, verify the claim, look at frequency rather than presence, then enforce at the layer that actually enforces.</p>
<h2>Get the user agent into the log, with the real client IP</h2>
<p>nginx's default <code>combined</code> format already carries <code>$http_user_agent</code>. If the site runs a custom format, add it.</p>
<pre><code class="language-nginx">log_format withua '$remote_addr - $remote_user [$time_local] '
                  '"$request" $status $body_bytes_sent '
                  '"$http_referer" "$http_user_agent" '
                  'xff="$http_x_forwarded_for" rt=$request_time';

access_log /var/log/nginx/access.log withua;
</code></pre>
<p>Apache's <code>combined</code> nickname includes <code>%{User-Agent}i</code> for the same reason. Confirm with one live request:</p>
<pre><code class="language-bash">tail -n 1 /var/log/nginx/access.log
</code></pre>
<p>Behind a CDN or a load balancer, <code>$remote_addr</code> is the edge node, not the crawler — and every verification step below will then check the wrong address. Log <code>$http_x_forwarded_for</code> or the CDN's client-IP header as well, and use that column for the address, remembering the header is only trustworthy when the request came through your own proxy.</p>
<p>Retention matters more than people expect. Seven days of logs cannot separate a crawler that fetches everything hourly from one that fetches everything weekly.</p>
<h2>Count the strings first</h2>
<p>Two commands get you most of the way before you write any code.</p>
<pre><code class="language-bash">zcat -f /var/log/nginx/access.log* \
  | awk -F'"' 'NF &gt;= 6 { print $6 }' \
  | sort | uniq -c | sort -rn | head -40
</code></pre>
<p>The <code>-F'"'</code> split works because the combined format puts the user agent between the fifth and sixth quote, and <code>$6</code> is the string. <code>NF &gt;= 6</code> skips malformed lines, which show up whenever a UA itself contains a quote.</p>
<p>The same census per day:</p>
<pre><code class="language-bash">zcat -f /var/log/nginx/access.log* \
  | awk -F'"' 'NF &gt;= 6 { split($1, a, "["); split(a[2], t, ":"); print t[1] "|" $6 }' \
  | sort | uniq -c | sort -rn | head -40
</code></pre>
<p><code>t[1]</code> is the date field of the timestamp. What you get is hits per UA per day, which is enough to tell a crawler that appeared once from one that has been running for a week.</p>
<h2>Verify the claim before you act on it</h2>
<p>Reverse DNS with a forward confirmation is the cheapest test that means anything. Look up the PTR record for the address, resolve that hostname forward, and check the original IP is in the answer. A PTR record alone proves nothing; anyone can point one at a name that looks official.</p>
<p>The script below reads a log file and prints one row per UA string: hits, days seen, hits per day, distinct addresses, how many of a five-address sample passed the forward-confirmed check, the status mix, and megabytes served.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""UA census with reverse-DNS confirmation.

Usage: python3 ua_census.py /var/log/nginx/access.log &gt; census.csv
"""
import csv
import re
import socket
import sys
from collections import defaultdict

LINE = re.compile(
    r'^(?P&lt;ip&gt;\S+) \S+ \S+ \[(?P&lt;ts&gt;[^\]]+)\] '
    r'"(?P&lt;req&gt;[^"]*)" (?P&lt;status&gt;\d{3}) (?P&lt;size&gt;\S+) '
    r'"[^"]*" "(?P&lt;ua&gt;[^"]*)"'
)

VERIFIED = {}


def confirmed_host(ip):
    """Return the PTR host only if a forward lookup resolves back to the same IP."""
    if ip in VERIFIED:
        return VERIFIED[ip]
    host = ""
    try:
        ptr = socket.gethostbyaddr(ip)[0]
        family = socket.AF_INET6 if ":" in ip else socket.AF_INET
        addrs = {info[4][0] for info in socket.getaddrinfo(ptr, None, family)}
        if ip in addrs:
            host = ptr
    except (socket.herror, socket.gaierror, OSError):
        host = ""
    VERIFIED[ip] = host
    return host


def main(path):
    stats = defaultdict(lambda: {
        "hits": 0, "ips": set(), "ok": 0, "bad": 0, "bytes": 0, "days": set(),
    })
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            m = LINE.match(line)
            if not m:
                continue
            ua = m.group("ua") or "-"
            rec = stats[ua]
            rec["hits"] += 1
            rec["ips"].add(m.group("ip"))
            status = m.group("status")
            if status.startswith("2"):
                rec["ok"] += 1
            elif status.startswith("4"):
                rec["bad"] += 1
            try:
                rec["bytes"] += int(m.group("size"))
            except ValueError:
                pass
            rec["days"].add(m.group("ts").split(":")[0])

    out = csv.writer(sys.stdout)
    out.writerow(["ua", "hits", "days", "hits_per_day", "ips",
                  "ptr_ok_sample", "sample_ptr", "pct_2xx", "pct_4xx", "mb"])
    for ua, rec in sorted(stats.items(), key=lambda kv: -kv[1]["hits"]):
        sample = sorted(rec["ips"])[:5]
        ptrs = sorted({h for h in (confirmed_host(ip) for ip in sample) if h})
        days = max(len(rec["days"]), 1)
        out.writerow([
            ua[:120],
            rec["hits"],
            len(rec["days"]),
            round(rec["hits"] / days, 1),
            len(rec["ips"]),
            "{}/{}".format(len(ptrs), len(sample)),
            " ".join(ptrs),
            round(100.0 * rec["ok"] / rec["hits"], 1),
            round(100.0 * rec["bad"] / rec["hits"], 1),
            round(rec["bytes"] / 1048576.0, 1),
        ])


if __name__ == "__main__":
    main(sys.argv[1])
</code></pre>
<p>PTR lookups are network calls, so the script caps them at five addresses per UA; a million-line log would otherwise turn into an hour of DNS. Raise the cap over a slice of log if you need a better sample.</p>
<p>Read the columns together. A claimed crawler with <code>ptr_ok_sample</code> at 5/5 and a steady <code>hits_per_day</code> behaves like the thing it names. The same string at 0/5, spread over 40 distinct addresses, is either a spoofer or an operator that publishes no PTR records at all — and both go in the same bucket, unknown client, when you decide.</p>
<h2>Frequency is what costs you</h2>
<p>A crawler that fetches 200 URLs once a week is a rounding error. One that fetches 20,000 URLs an hour is a different problem. Peak rate per minute, per UA:</p>
<pre><code class="language-bash">zcat -f /var/log/nginx/access.log* \
  | awk -F'"' 'NF &gt;= 6 { split($1, a, "["); split(a[2], t, ":"); print t[1] " " t[2] ":" t[3] "|" $6 }' \
  | sort | uniq -c | sort -rn | head -20
</code></pre>
<p><code>t[2]:t[3]</code> is hour:minute, so the top rows are your peak minutes.</p>
<p>The numbers worth putting next to each other:</p>
<ul>
<li>requests per day, and whether that figure is climbing</li>
<li>distinct URLs per day, since a client hitting the same URL 5000 times is a loop rather than a crawl</li>
<li>the 4xx share, because a high one usually means the client works from an old URL list and you pay CPU for 404 pages</li>
<li>megabytes served, which is the part the host bills for</li>
<li>peak requests per minute, the number that decides whether you need a rate limit at all</li>
</ul>
<h2>Enforce where enforcement exists</h2>
<p>robots.txt is a request. Clients that respect it back off; the ones you worry about are defined by not respecting it. Still worth writing, because it costs nothing:</p>
<pre><code>User-agent: GPTBot
Disallow: /

User-agent: *
Allow: /
</code></pre>
<p>For actual blocking, do it at nginx or at the CDN edge. A <code>map</code> plus <code>return</code> is the least surprising way in nginx:</p>
<pre><code class="language-nginx">map $http_user_agent $blocked_ua {
    default        0;
    "~*GPTBot"     1;
    "~*ClaudeBot"  1;
    "~*CCBot"      1;
}

server {
    ...
    if ($blocked_ua) {
        return 403;
    }
}
</code></pre>
<p><code>return</code> inside <code>if</code> is one of the documented safe uses. Be clear about what it does: it matches a string, so it blocks the honest client and the spoofer together, and it does nothing at all about a fetcher sending a browser UA.</p>
<p>If slowing a burst beats blocking it, rate-limit per address on the PHP location and answer 429 with <code>Retry-After</code>:</p>
<pre><code class="language-nginx">limit_req_zone $binary_remote_addr zone=perip:10m rate=120r/m;

server {
    location ~ \.php$ {
        limit_req zone=perip burst=30 nodelay;
        limit_req_status 429;
        add_header Retry-After 60 always;
    }
}
</code></pre>
<p>Set the rate above what a normal page load with its assets needs, watch the 429 count for a day, then adjust. Shared corporate NAT addresses will trip a limit set too low, so the 429 log is part of the tuning, not an afterthought.</p>
<p>If parsing logs is not the job you want, <a href="https://aumcreate.com/plugins/aumcrawl?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-detect-ai-crawler-ua-frequency">AumCrawl</a> records hits per crawler inside WordPress and lets you allow or block each one separately — same decision, counting already done.</p>
<h2>Where this falls apart</h2>
<p><strong>Tokens that never appear in a UA.</strong> Some AI opt-out tokens exist only for robots.txt. Grep for them in an access log and you get zero rows forever, because the fetching is done by the operator's general crawler. Absence from the log is not absence of the crawl.</p>
<p><strong>Quote characters inside the UA.</strong> A UA containing <code>"</code> shifts every field after it in a <code>-F'"'</code> split. Run the awk and the Python parser over the same file; if the counts disagree, this is why.</p>
<p><strong>Truncation.</strong> Some clients send long strings. Check that the full UA landed in the log rather than the first 100 characters, or you will bucket two different clients as one.</p>
<p><strong>IPv6.</strong> <code>gethostbyname</code> only handles IPv4 and fails quietly on v6 addresses. The script uses <code>getaddrinfo</code> with the family chosen from the address itself, which is the part that keeps it honest.</p>
<p><strong>Shared hostnames.</strong> A PTR record ending in a cloud provider's domain means the address sits in a data centre, not that it belongs to the operator you have in mind. Treat the domain suffix as a hint and the forward-confirmed match as the fact.</p>
<p><strong>Fetches that look like users.</strong> When someone asks an assistant about a page and the assistant reads it live, that request can arrive with an ordinary browser UA from a data-centre address. No UA rule catches it. A referrer or an ASN lookup sometimes hints at it, and neither is reliable enough to act on alone.</p>
<p>Start with the census, confirm a handful of addresses per UA before blocking anything, and keep raw logs long enough that next month's comparison means something.</p>
]]></content:encoded></item><item><title><![CDATA[Translating a whole WordPress site through the built-in AI connector, without a second API key]]></title><description><![CDATA[A client hands over a site with a provider already connected on the connector screen under Settings.]]></description><link>https://aumcreate.hashnode.dev/translating-a-whole-wordpress-site-through-the-built-in-ai-connector-without-a-second-api-key</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/translating-a-whole-wordpress-site-through-the-built-in-ai-connector-without-a-second-api-key</guid><category><![CDATA[wp-cli]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Thu, 29 Oct 2026 06:14:29 GMT</pubDate><content:encoded><![CDATA[<p>A client hands over a site with a provider already connected on the connector screen under Settings. You install a translation plugin, and it asks for an API key on its own settings page. The same WordPress install now holds two credentials for one job, and the one the site owner can actually find is the one your plugin does not use.</p>
<p>The built-in connector covers the text. It does not cover the site around the text. Below is the part it can do, the code to drive it from a small plugin, and the places where it stops and you need something else.</p>
<h2>What the connector gives a plugin</h2>
<p>The connector is a client that ships with recent WordPress builds. It reads a credential the site owner stored — in the database, or from a constant if the host injects one — and exposes a prompt API to plugin code. Plugins call the client; the client talks to whichever provider is connected.</p>
<p>Three consequences matter for a translation job:</p>
<ul>
<li>The key is shared. One credential serves the translation plugin, the chatbot installer, the schema plugin, anything else on the site that generates text.</li>
<li>Your code does not pick the provider per request. Whatever the site owner connected is what answers.</li>
<li>Nothing about languages, URLs or alternates is included. You get generated text back.</li>
</ul>
<p>If nothing is connected, plugins calling the client fail, and so will the code below. That is the first thing to check.</p>
<h2>Check the site before writing anything</h2>
<pre><code class="language-bash">wp eval 'var_dump( function_exists( "wp_ai_client_prompt" ) );'
</code></pre>
<p>A <code>true</code> means the client is loaded. Then a real call, which tells you whether a usable credential is behind it:</p>
<pre><code class="language-bash">wp eval '
if ( ! function_exists( "wp_ai_client_prompt" ) ) { echo "no AI client", PHP_EOL; return; }
$r = wp_ai_client_prompt( "Reply with the single word ok" )-&gt;generate_text();
if ( is_object( $r ) &amp;&amp; method_exists( $r, "get_text" ) ) { echo $r-&gt;get_text(), PHP_EOL; }
elseif ( is_wp_error( $r ) ) { echo $r-&gt;get_error_message(), PHP_EOL; }
else { echo (string) $r, PHP_EOL; }
'
</code></pre>
<p>An error about a missing provider means the client is present and the credential is not usable. Fix that in the admin before going further; there is no plugin-side workaround that does not involve a second key.</p>
<h2>The translation call</h2>
<p>Drop this in a small plugin or an mu-plugin. No settings page, no key field.</p>
<pre><code class="language-php">&lt;?php
defined( 'ABSPATH' ) || exit;

function aum_ai_ready(): bool {
    return function_exists( 'wp_ai_client_prompt' );
}

function aum_translate_html( string $html, string $lang, string $glossary = '' ): string {
    if ( ! aum_ai_ready() ) {
        throw new RuntimeException( 'No AI client available on this site.' );
    }

    $prompt = implode( PHP_EOL, array_filter( [
        "Translate the HTML below into {$lang}.",
        'Keep every tag, attribute, shortcode and URL byte-identical.',
        'Do not translate product codes, model numbers, or any string listed in the glossary.',
        'Return the translated HTML only, with no commentary and no code fence.',
        $glossary !== '' ? 'Glossary:' . PHP_EOL . $glossary : '',
        '---',
        $html,
    ] ) );

    $builder = wp_ai_client_prompt( $prompt );
    if ( method_exists( $builder, 'using_temperature' ) ) {
        $builder = $builder-&gt;using_temperature( 0.1 );
    }

    $out = $builder-&gt;generate_text();

    if ( is_wp_error( $out ) ) {
        throw new RuntimeException( $out-&gt;get_error_message() );
    }
    if ( is_object( $out ) &amp;&amp; method_exists( $out, 'get_text' ) ) {
        $out = $out-&gt;get_text();
    }

    return trim( (string) $out );
}
</code></pre>
<p>Why it looks like this:</p>
<ul>
<li><code>using_temperature</code> and <code>get_text</code> are guarded with <code>method_exists</code>. The result object differs between client builds, and the guard costs nothing versus a fatal on a site carrying a different one.</li>
<li>Temperature is low on purpose. You want consistent terminology across 300 posts, not variety.</li>
<li>The prompt states the invariants — tags, shortcodes, URLs — because a model will otherwise helpfully rewrite a link or drop a <code>&lt;br&gt;</code>.</li>
<li>The glossary is where product names, model codes and legal terms survive verbatim. Keep it in a file rather than the database so it survives a migration.</li>
</ul>
<h2>Chunk before you send</h2>
<p>Long posts get truncated, and truncated output looks like a model failure. Split on block boundaries and send pieces you can rejoin.</p>
<pre><code class="language-php">function aum_chunks( string $html, int $limit = 4000 ): array {
    $parts  = preg_split( '/(?&lt;=&lt;\/p&gt;|&lt;\/li&gt;|&lt;\/h2&gt;)/i', $html ) ?: [ $html ];
    $chunks = [];
    $buf    = '';

    foreach ( $parts as $part ) {
        if ( $buf !== '' &amp;&amp; strlen( $buf ) + strlen( $part ) &gt; $limit ) {
            $chunks[] = $buf;
            $buf      = '';
        }
        $buf .= $part;
    }
    if ( trim( $buf ) !== '' ) {
        $chunks[] = $buf;
    }

    return $chunks;
}
</code></pre>
<p><code>strlen</code> counts bytes, so 4000 bytes is roughly 4000 Latin characters and considerably fewer Chinese or Japanese ones. Set the limit conservatively and measure against the largest post on the site.</p>
<h2>Store with a hash, never on render</h2>
<pre><code class="language-php">function aum_translate_post( int $post_id, string $lang ): string {
    $post = get_post( $post_id );
    if ( ! $post instanceof WP_Post ) {
        throw new RuntimeException( "No post {$post_id}" );
    }

    $store = (array) get_post_meta( $post_id, '_aum_i18n', true );
    $hash  = md5( $post-&gt;post_title . '|' . $post-&gt;post_content . '|' . $lang );

    if ( isset( $store[ $lang ]['hash'] ) &amp;&amp; $store[ $lang ]['hash'] === $hash ) {
        return (string) $store[ $lang ]['content'];
    }

    $content = '';
    foreach ( aum_chunks( $post-&gt;post_content ) as $chunk ) {
        $content .= aum_translate_html( $chunk, $lang );
    }

    $store[ $lang ] = [
        'hash'    =&gt; $hash,
        'title'   =&gt; aum_translate_html( $post-&gt;post_title, $lang ),
        'content' =&gt; $content,
        'at'      =&gt; time(),
    ];
    update_post_meta( $post_id, '_aum_i18n', $store );

    return $content;
}
</code></pre>
<p>The hash is the point. Editing one paragraph invalidates that language and nothing else; re-running the command over 400 posts after a wording change on the homepage costs one post worth of tokens.</p>
<p>Two rules for the storage:</p>
<ul>
<li>Never call the model inside <code>the_content</code>. A page render that waits on a network call adds seconds, and a traffic spike adds a bill.</li>
<li>Keep the source hash next to the translation so a later run can tell stale from current without diffing text.</li>
</ul>
<h2>Check the shape of what came back</h2>
<p>Models drop an <code>&lt;li&gt;</code> or eat a shortcode often enough that you should verify before saving.</p>
<pre><code class="language-php">function aum_shape_ok( string $src, string $out ): bool {
    foreach ( [ '&lt;a ', '&lt;h2', '&lt;h3', '&lt;img ', '&lt;ul', '&lt;table' ] as $tag ) {
        if ( substr_count( $src, $tag ) !== substr_count( $out, $tag ) ) {
            return false;
        }
    }

    $grab = static function ( string $s ): array {
        preg_match_all( '/\[[a-z0-9_-]+/i', $s, $m );
        $found = array_values( array_unique( $m[0] ) );
        sort( $found );
        return $found;
    };

    return $grab( $src ) === $grab( $out );
}
</code></pre>
<p>Retry once with the same prompt plus a note about which tags went missing. If it fails again, keep the source text for that chunk and log the post ID — a mixed-language page is worse than one that needs a manual pass.</p>
<h2>Run it from the command line</h2>
<pre><code class="language-php">if ( defined( 'WP_CLI' ) &amp;&amp; WP_CLI ) {
    WP_CLI::add_command( 'aum-translate', function ( array $args ): void {
        [ $post_id, $lang ] = $args;
        $html = aum_translate_post( (int) $post_id, $lang );
        WP_CLI::success( sprintf( '%d characters stored for %s', strlen( $html ), $lang ) );
    } );
}
</code></pre>
<p>One post first, then read the stored meta before trusting anything:</p>
<pre><code class="language-bash">wp aum-translate 123 de
wp post meta get 123 _aum_i18n --format=json
</code></pre>
<p>Then a batch, with a pause between jobs. Action Scheduler if some plugin already registers it, <code>wp_schedule_single_event</code> otherwise, or a shell loop over post IDs with <code>sleep 2</code>. The point is that nothing sends 400 requests in one go.</p>
<h2>Where the connector stops</h2>
<p>Everything below sits outside the credential you just reused:</p>
<ul>
<li>URLs. Translated content needs its own path, and the connector has no opinion about what it should be.</li>
<li><code>hreflang</code> alternates and a sitemap that lists the translated URLs.</li>
<li>A language switcher, translated menus, translated widgets.</li>
<li>Slugs. Do not take a slug from model output; generate it with <code>sanitize_title()</code> on the translated title and keep a mapping of old path to new path so inbound links keep working.</li>
<li>Theme and plugin string translation. The <code>.po</code>/<code>.mo</code> layer is a different mechanism entirely.</li>
<li>Structured data. Translate the values in a JSON-LD block and leave keys, <code>@type</code> and <code>url</code> untouched; a translated <code>@type</code> breaks the markup silently.</li>
<li>Shop fields: variation labels, attribute terms, checkout strings.</li>
<li>Review. Nothing in the connector tells you whether a translation is correct.</li>
</ul>
<p>Turning stored translations into routes, alternates and a switcher is its own layer. Plugins that run on the same connector do that part: <a href="https://aumcreate.com/plugins/aumlang?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-wp-ai-translation-no-extra-key">AumLang</a> is one, and it also covers product pages in a shop. Writing that layer yourself is reasonable if you have a fixed page set and can map URLs by hand.</p>
<h2>The operational limits that bite later</h2>
<p><strong>The bill belongs to the site owner.</strong> The credential is theirs and tokens are billed to their provider account. Estimate before the first full run: translate one long post, note the character count, multiply by the site.</p>
<p><strong>Retries need a ceiling.</strong> Two attempts per chunk, then mark the post failed and move on. A retry loop against a rate-limited provider turns a partial run into a stuck queue.</p>
<p><strong>Output changes between runs.</strong> Retranslating months later with a different connected model gives different wording for the same source. If consistency matters more than freshness, trust the hash and only retranslate on content change.</p>
<p><strong>What you send leaves the site.</strong> Drafts, internal notes, anything inside the content goes to the provider. Strip shortcodes that render private data, and do not queue unpublished posts without asking.</p>
<p><strong>There is no fallback when the provider is down.</strong> The job fails and the queue holds. Log it and let the scheduled run pick it up.</p>
<p>Practical order: verify the client, translate one post, read the meta, run twenty, read those, then run the rest overnight with a delay between requests.</p>
]]></content:encoded></item><item><title><![CDATA[Translating WooCommerce product pages without breaking variants and attributes]]></title><description><![CDATA[A translated attribute value can stop a variation from matching.]]></description><link>https://aumcreate.hashnode.dev/translating-woocommerce-product-pages-without-breaking-variants-and-attributes</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/translating-woocommerce-product-pages-without-breaking-variants-and-attributes</guid><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Wed, 28 Oct 2026 03:59:27 GMT</pubDate><content:encoded><![CDATA[<p>A translated attribute value can stop a variation from matching. The product page shows <code>Blau</code> in the dropdown, the form posts <code>Blau</code>, and the variation meta still says <code>blue</code> or <code>Blue</code>. WooCommerce finds no variation for that combination, so the price and Add to cart button disappear.</p>
<p>The safe split is display text on one side, matching keys on the other. Product titles and descriptions are display text. Attribute slugs, custom attribute values, variation meta keys, and SKUs are matching keys. Translate the first group. Leave the second group in one canonical language.</p>
<table>
<thead>
<tr>
<th>What the customer sees</th>
<th>Where it lives</th>
<th>Translate it?</th>
</tr>
</thead>
<tbody><tr>
<td>Product title</td>
<td><code>wp_posts.post_title</code></td>
<td>Yes, store a translation in meta and filter the product getter</td>
</tr>
<tr>
<td>Short and long description</td>
<td><code>post_excerpt</code> and <code>post_content</code></td>
<td>Yes, filter the product getters</td>
</tr>
<tr>
<td>Attribute label, such as Color</td>
<td>taxonomy label or <code>_product_attributes[name]</code></td>
<td>Yes, filter <code>woocommerce_attribute_label</code></td>
</tr>
<tr>
<td>Attribute option label, such as Blue</td>
<td>term name or custom attribute value</td>
<td>Yes, filter the option label only</td>
</tr>
<tr>
<td>Attribute slug, such as <code>blue</code></td>
<td>term slug</td>
<td>No</td>
</tr>
<tr>
<td>Custom attribute value, such as <code>Blue</code></td>
<td><code>_product_attributes[value]</code> and <code>attribute_color</code> meta</td>
<td>No</td>
</tr>
<tr>
<td>Variation record</td>
<td><code>product_variation</code> post and <code>attribute_*</code> meta</td>
<td>No</td>
</tr>
<tr>
<td>SKU</td>
<td><code>_sku</code></td>
<td>No</td>
</tr>
</tbody></table>
<h2>Store the translation beside the product</h2>
<p>Do not overwrite the original title or description. Store the translated strings in post meta keyed by language:</p>
<pre><code class="language-php">update_post_meta(123, '_aum_i18n', [
    'de' =&gt; [
        'title' =&gt; 'Edelstahl-Trinkflasche 750 ml',
        'short_description' =&gt; 'Doppelwandig isoliert, hält 24 Stunden kalt.',
        'description' =&gt; '...',
    ],
]);
</code></pre>
<p>For attribute labels and option names, a site option is enough when the same labels are reused across products:</p>
<pre><code class="language-php">update_option('aum_attribute_labels', [
    'de' =&gt; [
        'pa_color' =&gt; 'Farbe',
        'pa_size' =&gt; 'Größe',
        'color' =&gt; 'Farbe',
        'size' =&gt; 'Größe',
    ],
]);

update_option('aum_variation_option_names', [
    'de' =&gt; [
        'Blue' =&gt; 'Blau',
        'Red' =&gt; 'Rot',
        'Small' =&gt; 'Klein',
        'Large' =&gt; 'Groß',
    ],
]);
</code></pre>
<p>The keys must match the stored label or option name exactly. If your attribute is global, the label passed to the filter is usually the taxonomy name, such as <code>pa_color</code>. For a custom product attribute, it is the array key from <code>_product_attributes</code>, such as <code>color</code>.</p>
<h2>Make the product getters return the translation</h2>
<p>Put this in <code>wp-content/mu-plugins/woo-product-display-i18n.php</code>. It translates the display fields and leaves the variation keys alone.</p>
<pre><code class="language-php">&lt;?php
/**
 * Plugin Name: Woo Product Display i18n
 * Description: Translate WooCommerce display text without touching variation keys.
 */

function aum_lang(): string {
    $lang = isset($_COOKIE['aum_lang']) ? sanitize_key($_COOKIE['aum_lang']) : 'en';
    return in_array($lang, ['en', 'de'], true) ? $lang : 'en';
}

function aum_product_text(int $product_id, string $field, string $fallback): string {
    $lang = aum_lang();
    if ($lang === 'en') {
        return $fallback;
    }

    $data = get_post_meta($product_id, '_aum_i18n', true);
    if (!is_array($data) || empty($data[$lang][$field])) {
        return $fallback;
    }

    return (string) $data[$lang][$field];
}

add_filter('woocommerce_product_get_name', function ($name, $product) {
    if (!$product instanceof WC_Product) {
        return $name;
    }
    return aum_product_text($product-&gt;get_id(), 'title', $name);
}, 10, 2);

add_filter('woocommerce_product_get_short_description', function ($text, $product) {
    if (!$product instanceof WC_Product) {
        return $text;
    }
    return aum_product_text($product-&gt;get_id(), 'short_description', $text);
}, 10, 2);

add_filter('woocommerce_product_get_description', function ($text, $product) {
    if (!$product instanceof WC_Product) {
        return $text;
    }
    return aum_product_text($product-&gt;get_id(), 'description', $text);
}, 10, 2);

add_filter('the_title', function ($title, $post_id) {
    if (is_admin() || get_post_type($post_id) !== 'product') {
        return $title;
    }
    return aum_product_text((int) $post_id, 'title', $title);
}, 10, 2);

add_filter('woocommerce_attribute_label', function ($label, $name, $product = null) {
    $lang = aum_lang();
    if ($lang === 'en') {
        return $label;
    }

    $labels = get_option('aum_attribute_labels', []);
    return isset($labels[$lang][$name]) ? (string) $labels[$lang][$name] : $label;
}, 10, 3);

add_filter('woocommerce_variation_option_name', function ($name) {
    $lang = aum_lang();
    if ($lang === 'en') {
        return $name;
    }

    $names = get_option('aum_variation_option_names', []);
    return isset($names[$lang][$name]) ? (string) $names[$lang][$name] : $name;
});
</code></pre>
<p><code>aum_lang()</code> reads a cookie in this example. Replace that function with your language router if the site already sets a current language. The important part is that the filters change output strings only. They do not change the values WooCommerce uses to match a variation.</p>
<p>The <code>the_title</code> filter is there because the default WooCommerce product loop calls <code>get_the_title()</code>, not the product getter. It is guarded to product post type and the front-end. If a theme uses the product getter everywhere, you can leave it out.</p>
<h2>Attribute labels and option names</h2>
<p>A global attribute has a taxonomy such as <code>pa_color</code>. The term slug is the matching key:</p>
<pre><code class="language-text">term name: Blue
term slug: blue
variation meta: attribute_pa_color = blue
</code></pre>
<p>Translating the term name to <code>Blau</code> is safe if the slug stays <code>blue</code>. Translating the slug to <code>blau</code> is not. The variation meta still says <code>blue</code>, the form posts <code>blau</code>, and the match fails.</p>
<p>A custom product attribute is stored in <code>_product_attributes</code>:</p>
<pre><code class="language-php">'color' =&gt; [
    'name' =&gt; 'Color',
    'value' =&gt; 'Blue | Red',
    'position' =&gt; 0,
    'is_visible' =&gt; 1,
    'is_variation' =&gt; 1,
    'is_taxonomy' =&gt; 0,
],
</code></pre>
<p>The variation meta for that attribute is:</p>
<pre><code class="language-text">attribute_color = Blue
</code></pre>
<p>If you change <code>Blue</code> to <code>Blau</code> in the <code>value</code> string, WooCommerce will render <code>Blau</code> as the option value and post <code>Blau</code>. The variation meta still says <code>Blue</code>, so the match fails. The filter above leaves <code>value</code> untouched and only changes the option label.</p>
<p>If you use layered navigation or attribute archive pages, those widgets may read term names directly. The filter above covers the product page dropdown. A term display filter is a separate piece; if you add one, return a cloned term with a changed <code>name</code>, not a changed <code>slug</code>, and keep it out of admin.</p>
<h2>Variations stay as they are</h2>
<p>Keep one variation record for each combination. Do not create a German variation with <code>attribute_pa_color = blau</code> while the product page sends <code>blue</code>. Do not rewrite <code>attribute_*</code> meta during a translation import. Do not translate <code>_sku</code>; it is an inventory key.</p>
<p>WooCommerce builds the variation label from the product name and attribute labels. Since those are filtered, the customer sees the translated label without any change to the variation post. If you use a variation description, store the translated text in meta and filter the variation description getter; do not overwrite <code>_variation_description</code> with the translated string.</p>
<p>A separate product record per language is a different architecture. It can give you separate URLs, but then you own variation duplication, stock sync, price sync, and order routing. For a small catalog, one product record with translated display fields is easier to keep consistent. If you need both languages indexed, add a language router and hreflang; the meta and filter layer above does not create URLs by itself.</p>
<h2>Check the switch before you take an order</h2>
<ul>
<li>Switch to the translated language on a variable product. The dropdown labels should change, but the option values in the page source should stay <code>blue</code> or <code>Blue</code>.</li>
<li>Add each variation to the cart. The cart line should show the translated product name and the correct variation. The variation ID and SKU should match the original.</li>
<li>Change quantity and submit the variation form again. WooCommerce should still find the same variation.</li>
<li>Place a test order. Check the order admin screen and the order email for the correct item and variation.</li>
<li>Check the product archive title and the single product tabs.</li>
<li>If you use layered navigation, check the filter labels separately.</li>
</ul>
<p>If you do not want to maintain the meta and filters yourself, AumLang is our free WordPress plugin for AI translation, hreflang, and WooCommerce product-page translation: <a href="https://aumcreate.com/plugins/aumlang?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-woo-product-fields-translation">AumLang</a>.</p>
]]></content:encoded></item><item><title><![CDATA[How to prepare a product CSV with variants and images so the import doesn't half-fail]]></title><description><![CDATA[The import finishes, the screen says it worked, and then the catalog tells a different story: variable products with no variations attached, a few pr…]]></description><link>https://aumcreate.hashnode.dev/how-to-prepare-a-product-csv-with-variants-and-images-so-the-import-doesn-t-half-fail</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/how-to-prepare-a-product-csv-with-variants-and-images-so-the-import-doesn-t-half-fail</guid><category><![CDATA[Import]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Wed, 28 Oct 2026 03:55:48 GMT</pubDate><content:encoded><![CDATA[<p>The import finishes, the screen says it worked, and then the catalog tells a different story: variable products with no variations attached, a few product names with <code>Ã¤</code> in them, and images that resolve to a broken placeholder. Nothing failed loudly enough to stop the run, so the damage is only visible afterwards.</p>
<p>Almost all of it is decided before the file reaches the importer.</p>
<h2>Encoding first, because it corrupts silently</h2>
<p>The file has to be UTF-8. Excel on a Windows machine saves 'CSV (Comma delimited)' in the system code page, which turns every non-ASCII character into mojibake that survives the import and lands in the database.</p>
<pre><code class="language-bash">file -i products.csv
</code></pre>
<p>If it reports <code>charset=iso-8859-1</code> or <code>unknown-8bit</code>, convert rather than re-exporting by hand:</p>
<pre><code class="language-bash">iconv -f WINDOWS-1252 -t UTF-8 products.csv &gt; products-fixed.csv
</code></pre>
<p>A UTF-8 BOM is worse than useless here: it attaches itself to the first header name, so the importer never finds that column and quietly skips it. Strip it:</p>
<pre><code class="language-bash">sed -i '1s/^\xEF\xBB\xBF//' products-fixed.csv
</code></pre>
<p>If you are producing the file in a spreadsheet, save through 'Text CSV' and set the character set to UTF-8 explicitly. While you are in there, watch for the other spreadsheet habits that break product data:</p>
<ul>
<li>SKUs with leading zeros, which come back as <code>1234</code> instead of <code>001234</code> unless the column is formatted as text</li>
<li>Long numeric IDs turned into <code>1.23457E+14</code></li>
<li>Values like <code>1/2</code> auto-converted to a date</li>
<li>Prices typed with a comma decimal separator, or with a currency symbol still attached</li>
<li>Descriptions pasted from a word processor, carrying curly quotes and stray markup</li>
</ul>
<p>Prices should be plain decimals with a dot. Stock counts should be integers. An empty cell is not the same as <code>0</code>, and the importer will treat them differently.</p>
<h2>Check the structure with a script, not with your eyes</h2>
<p>A CSV with quoted fields cannot be validated by counting commas. This script checks the parts that cause silent partial imports and needs nothing beyond Python 3:</p>
<pre><code class="language-python">#!/usr/bin/env python3
import csv, sys
from collections import Counter
from urllib.parse import urlparse

path = sys.argv[1]
with open(path, newline='', encoding='utf-8-sig') as fh:
    reader = csv.DictReader(fh)
    header = reader.fieldnames or []
    rows = list(reader)

issues = []
for required in ('Type', 'SKU', 'Name', 'Parent', 'Images'):
    if required not in header:
        issues.append('missing column: ' + required)

for i, row in enumerate(rows, start=2):
    if None in row:
        issues.append(f'line {i}: more fields than the header row')
    for key, value in row.items():
        if key and value and value != value.strip():
            issues.append(f'line {i}: whitespace around {key}')

counts = Counter((r.get('SKU') or '').strip() for r in rows)
for sku, n in counts.items():
    if not sku:
        issues.append('row with an empty SKU')
    elif n &gt; 1:
        issues.append(f'SKU {sku} appears {n} times')

parents = {s for s in counts if s}
for i, row in enumerate(rows, start=2):
    if (row.get('Type') or '').strip() == 'variation':
        parent = (row.get('Parent') or '').strip()
        if parent not in parents:
            issues.append(f'line {i}: parent SKU {parent!r} has no row in this file')

for i, row in enumerate(rows, start=2):
    urls = [u.strip() for u in (row.get('Images') or '').split(',') if u.strip()]
    for url in urls:
        parsed = urlparse(url)
        if parsed.scheme not in ('http', 'https') or not parsed.netloc:
            issues.append(f'line {i}: not an absolute image URL: {url}')

print('\n'.join(issues) if issues else 'no structural problems found')
print(f'{len(rows)} rows, {len(header)} columns')
</code></pre>
<p>Duplicate SKUs are the one that hurts most: the importer updates by SKU, so the second row overwrites the first and you end up with fewer products than rows you sent.</p>
<h2>Image addresses</h2>
<p>The importer downloads images from the URLs in the file, which means the URL has to be reachable from the server doing the import, not from your browser. A CDN with hotlink protection, a staging password, or a firewall that blocks non-browser user agents will all return a page instead of an image, and the row imports without it.</p>
<p>Check from the server:</p>
<pre><code class="language-bash">curl -sI 'https://cdn.example.com/img/a.jpg' | head -n 1
curl -sI 'https://cdn.example.com/img/a.jpg' | grep -i content-type
</code></pre>
<p>What the file needs:</p>
<ul>
<li>Absolute <code>https://</code> URLs. <code>/wp-content/uploads/a.jpg</code> is not a URL the importer can fetch.</li>
<li>Percent-encoded spaces and non-ASCII characters in paths.</li>
<li>One image per entry, multiple entries separated by commas inside a quoted field.</li>
<li>No cache-busting query strings on duplicates. The same photo referenced as <code>a.jpg</code> and <code>a.jpg?v=2</code> becomes two attachments in the media library.</li>
</ul>
<p>Large catalogs are worth doing in two passes: upload images to the media library first, then import the product rows pointing at the resulting URLs. It turns a long, failure-prone download step into a separate job you can rerun.</p>
<h2>Variants are two different kinds of rows in one file</h2>
<p>A variable product is a parent row plus one row per variation. Mixing the two up is what produces products that look right on the products screen and have nothing on the front end.</p>
<p>The parent row:</p>
<ul>
<li><code>Type</code> set to <code>variable</code></li>
<li>its own <code>SKU</code></li>
<li>the attribute name columns, for example <code>Attribute 1 name</code> and <code>Attribute 1 value(s)</code>, with all the values that variations will use</li>
<li><code>Attribute 1 visible</code> and <code>Attribute 1 global</code> set the way you want them used</li>
</ul>
<p>Each variation row:</p>
<ul>
<li><code>Type</code> set to <code>variation</code></li>
<li><code>Parent</code> set to the parent's SKU, exactly as written in the parent row</li>
<li>its own unique <code>SKU</code></li>
<li>one attribute value, matching one of the values declared on the parent</li>
<li>its own price, sale price, stock and weight</li>
</ul>
<p>The failure to watch for is a variation whose attribute value does not appear in the parent's value list. It either fails to attach or attaches to a variation nobody can select. If the parent declares <code>Small, Medium, Large</code> and a variation says <code>Small </code>, the trailing space is enough to break the match, which is why the validation script above flags whitespace.</p>
<p>Stock belongs on the variation row when variations are managed individually. Setting it only on the parent gives you a product that shows in stock while every variation is sold out.</p>
<h2>Per-variation images after the import</h2>
<p>The <code>Images</code> column covers the product image. When each variation needs its own photo, the reliable path is to import the catalog first and set the variation images afterwards from a mapping file of SKU to image URL, with the images already in the media library. Upload them:</p>
<pre><code class="language-bash">wp media import ./images/*.jpg --porcelain
</code></pre>
<p>Then a <code>variation-images.csv</code> with <code>sku,image_url</code> columns, and this script, run with <code>wp eval-file set-variation-images.php</code> from the WordPress root:</p>
<pre><code class="language-php">&lt;?php
$handle = fopen(__DIR__ . '/variation-images.csv', 'r');
if (!$handle) {
    WP_CLI::error('cannot open variation-images.csv');
}
fgetcsv($handle); // header
$done = 0;
while (($row = fgetcsv($handle)) !== false) {
    [$sku, $url] = array_map('trim', $row);
    $product_id = wc_get_product_id_by_sku($sku);
    if (!$product_id) {
        WP_CLI::warning("no product for $sku");
        continue;
    }
    $variation = wc_get_product($product_id);
    if (!$variation || !$variation-&gt;is_type('variation')) {
        WP_CLI::warning("$sku is not a variation");
        continue;
    }
    $attachment_id = attachment_url_to_postid($url);
    if (!$attachment_id) {
        WP_CLI::warning("image not in the media library: $url");
        continue;
    }
    $variation-&gt;set_image_id($attachment_id);
    $variation-&gt;save();
    $done++;
}
fclose($handle);
WP_CLI::success("$done variation images set");
</code></pre>
<p><code>attachment_url_to_postid()</code> only resolves URLs that belong to the current site's uploads, so the mapping file has to use the final URLs from this install, not the CDN source.</p>
<h2>Cleaning pass before the real run</h2>
<p>Work through this on the file itself, then import it into a copy of the site:</p>
<ul>
<li>Trim whitespace on SKU, Parent and every attribute column</li>
<li>Normalise categories to the importer's separator, <code>&gt;</code> between levels and a comma between distinct categories</li>
<li>Confirm every row has the same number of fields as the header, which the script checks</li>
<li>Decide what an empty stock cell should mean before the import, not after</li>
<li>Keep the header row exact: renamed columns are ignored, not guessed</li>
<li>Import a file of five rows first and open one product, one variation and one image in the browser</li>
</ul>
<p>Back up before the full run, and split large files so a timeout does not leave you unsure which half landed:</p>
<pre><code class="language-bash">wp db export before-import.sql
awk -v n=200 'NR==1{h=$0; next} {f=sprintf("part_%03d.csv", int((NR-2)/n)); if(!(f in seen)){print h &gt; f; seen[f]=1} print &gt; f}' products.csv
</code></pre>
<p>Each part keeps the header row. Import them in order, and if a part fails, you know exactly which SKUs to inspect instead of re-auditing the whole catalog. A working demo store built on the same column layout is at <a href="https://aumcreate.com/templates/ecommerce?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-csv-import-variant-images">https://aumcreate.com/templates/ecommerce?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-csv-import-variant-images</a> — it is our own demo, not a client project.</p>
]]></content:encoded></item><item><title><![CDATA[Which cache layer fixes which slowdown, judged from the response headers]]></title><description><![CDATA[The complaint never says which part is slow. It arrives as 'the site is slow', and the first reaction is to switch on some caching and see whether it…]]></description><link>https://aumcreate.hashnode.dev/which-cache-layer-fixes-which-slowdown-judged-from-the-response-headers</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/which-cache-layer-fixes-which-slowdown-judged-from-the-response-headers</guid><category><![CDATA[Redis]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Wed, 28 Oct 2026 03:51:49 GMT</pubDate><content:encoded><![CDATA[<p>The complaint never says which part is slow. It arrives as 'the site is slow', and the first reaction is to switch on some caching and see whether it feels better. That works often enough to be a trap: it hides the layer that was actually the problem, and the next person who has to debug the site has no idea which cache to clear.</p>
<p>Get the response to describe itself first.</p>
<h2>Read the response before touching a cache</h2>
<pre><code class="language-bash">for i in 1 2 3; do
  curl -s -o /dev/null \
    -w 'ttfb=%{time_starttransfer} total=%{time_total} bytes=%{size_download}\n' \
    https://example.com/
done
</code></pre>
<p>Send it more than once. If a full-page cache is doing its job, the later requests usually differ from the first, because the cache key repeats. If every request sits at the same TTFB with the same byte count, the HTML is being assembled from PHP and the database each time.</p>
<p>Then the headers:</p>
<pre><code class="language-bash">curl -sI https://example.com/ | grep -iE 'cache-control|age|x-cache|vary|etag'
</code></pre>
<p><code>Age</code> above zero means something in front of PHP served a stored copy. <code>X-Cache</code> is not standard, but plenty of setups emit <code>HIT</code> or <code>MISS</code> there. <code>Cache-Control: no-cache</code> with an <code>ETag</code> on the HTML means the browser revalidates each load, which is correct for pages that change.</p>
<p>Run both commands again with a logged-in cookie, and from a machine in another region. Where the numbers diverge is where the layer lives.</p>
<table>
<thead>
<tr>
<th>What you observe</th>
<th>Layer that can act on it</th>
<th>How to confirm</th>
<th>What it will not fix</th>
</tr>
</thead>
<tbody><tr>
<td>HTML TTFB high, identical from every region, anonymous visitors</td>
<td>full-page cache</td>
<td>second request drops, <code>X-Cache</code> flips to HIT</td>
<td>cart, checkout, logged-in, wp-admin</td>
</tr>
<tr>
<td>Slow only when logged in, in admin, or on cart and checkout</td>
<td>persistent object cache</td>
<td><code>redis-cli monitor</code> shows reads and writes during a page load</td>
<td>asset weight, network distance</td>
</tr>
<tr>
<td>Fast next to the server, slow for distant visitors</td>
<td>CDN / edge</td>
<td><code>Age</code> present from a remote host, assets served from the edge host</td>
<td>a slow origin: a miss still waits for PHP</td>
</tr>
<tr>
<td>Repeat visits re-download the same CSS, JS and images</td>
<td>browser cache headers</td>
<td><code>Cache-Control</code> on the asset response</td>
<td>the first visit, and the HTML</td>
</tr>
</tbody></table>
<h2>Full-page cache: the HTML is rebuilt on every request</h2>
<p>This is the nginx version, because the mechanics stay visible. Plugin-based page caches do the same job with less control over the key.</p>
<pre><code class="language-nginx"># http context
fastcgi_cache_path /var/cache/nginx/wp levels=1:2 keys_zone=wp:100m
                   inactive=60m max_size=2g use_temp_path=off;

map $http_cookie $wp_bypass_cookie {
    default 0;
    '~*wordpress_logged_in'   1;
    '~*woocommerce_cart_hash' 1;
    '~*wp-postpass'           1;
}
</code></pre>
<pre><code class="language-nginx">server {
    set $wp_bypass_uri 0;
    if ($request_uri ~* '^/(wp-admin/|wp-json/|cart/|checkout/|my-account/)') {
        set $wp_bypass_uri 1;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php-fpm.sock;   # match your pool
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        fastcgi_cache wp;
        fastcgi_cache_key '$scheme$request_method$host$request_uri';
        fastcgi_cache_valid 200 301 302 10m;
        fastcgi_cache_lock on;
        fastcgi_cache_bypass $wp_bypass_cookie $wp_bypass_uri;
        fastcgi_no_cache     $wp_bypass_cookie $wp_bypass_uri;
        add_header X-Cache $upstream_cache_status always;
    }
}
</code></pre>
<p>Two things bite people here. The exclusion list has to match the actual paths on the site: <code>/checkout/</code> in a shop that uses <code>/checkout-2/</code> is a cache serving a stranger's session page to the next visitor. And <code>add_header</code> is inherited all-or-nothing per level, so if the <code>location</code> block already has one, the server-level headers vanish from that response — keep <code>X-Cache</code> in the same block as the others.</p>
<p>Invalidation is the other half. <code>fastcgi_cache_purge</code> is a third-party module, not something you get for free. Without it, expiry is your only invalidation, so either keep <code>fastcgi_cache_valid</code> short or delete the cache directory from a publish hook:</p>
<pre><code class="language-bash">find /var/cache/nginx/wp -type f -delete
</code></pre>
<h2>Object cache: fast for anonymous visitors, slow for everyone signed in</h2>
<p>A full-page cache is bypassed exactly where logged-in traffic lives, which is where the database work piles up. First confirm a persistent object cache even exists:</p>
<pre><code class="language-bash">ls -l wp-content/object-cache.php
</code></pre>
<p>No drop-in, no persistent object cache, whatever a settings screen claims. Install one that talks to Redis or Memcached, then verify it is actually being used instead of trusting the green notice:</p>
<pre><code class="language-bash">redis-cli -h 127.0.0.1 -p 6379 monitor
</code></pre>
<p>Load a page in another terminal and watch for read and write commands tied to your key prefix. If the page renders and nothing appears, the drop-in is not connected.</p>
<pre><code class="language-bash">redis-cli info memory | grep used_memory_human
redis-cli config get maxmemory-policy
</code></pre>
<p>If Redis is shared with anything that expects keys to survive, an eviction policy that drops keys under pressure will drop those too — WooCommerce sessions among them. That shows up as random logouts, not as a slow page, and it is easy to misread as a caching win.</p>
<p>An object cache repeats expensive queries; it does not make one cheap. A page that takes two seconds to build because of a badly indexed meta query will take two seconds on a cold cache, and the cache will spend its memory holding the result. Look at the query before adding a layer in front of it.</p>
<h2>CDN: fast next to the server, slow everywhere else</h2>
<p>If TTFB from a machine near the origin is fine and TTFB from another continent is not, the origin is healthy and the distance is the problem. Put the static assets on the edge first — that is the cheapest win and it cannot break sessions. HTML at the edge needs rules: exclude responses that set cookies, respect <code>Vary: Cookie</code> if you cache HTML at all, and purge on deploy.</p>
<p>A CDN in front of a slow origin mostly serves slow pages from more locations. Fix the origin response first, then measure again from the remote host and look for a non-zero <code>Age</code> on a repeat request.</p>
<h2>Browser cache: the same files downloaded on every visit</h2>
<pre><code class="language-nginx">location ~* \.(?:css|js|woff2|png|jpe?g|webp|avif|svg)$ {
    expires 30d;
    access_log off;
}
</code></pre>
<p>Long lifetimes are safe for filenames that change when the content changes. WordPress appends <code>?ver=</code> to enqueued assets, and browsers treat a different query string as a different URL, so the version bump does bust the browser copy even though intermediaries may ignore query strings entirely. Do not set a long lifetime on HTML, and do not set <code>immutable</code> on anything whose URL stays the same across releases.</p>
<p>This layer only helps the second visit. If the complaint is about a first load, it is the wrong place to look.</p>
<h2>Order of work</h2>
<p>Measure with the curl loop, fix the origin query or PHP work if the object cache shows nothing to cache, then enable the full-page cache with the exclusions, then the object cache for the paths the page cache skips, then the edge, then the asset headers. Run the same curl loop at the end. If the numbers did not move, you changed the wrong layer, and at least you know which one it was not.</p>
<p>More WordPress operations notes live at <a href="https://aumcreate.com/blog?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-cache-layers-which-slow">https://aumcreate.com/blog?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-cache-layers-which-slow</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Turning WooCommerce into an inquiry list: the cart, the fields, the email, and where the request actually lives]]></title><description><![CDATA[The request usually shows up as an afterthought. The catalogue goes live, someone asks for a quote button, a form gets bolted onto a separate page, a…]]></description><link>https://aumcreate.hashnode.dev/turning-woocommerce-into-an-inquiry-list-the-cart-the-fields-the-email-and-where-the-request-actually-lives</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/turning-woocommerce-into-an-inquiry-list-the-cart-the-fields-the-email-and-where-the-request-actually-lives</guid><category><![CDATA[forms]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Wed, 28 Oct 2026 03:34:03 GMT</pubDate><content:encoded><![CDATA[<p>The request usually shows up as an afterthought. The catalogue goes live, someone asks for a quote button, a form gets bolted onto a separate page, and the sales inbox starts receiving "I want the 12V version" with no way to tell which of the three hundred products that refers to.</p>
<p>The other version of the same mistake is keeping the cart and telling customers to check out and wait for an email. You then get orders sitting in <code>wc-processing</code> with no delivery country, no volume, no application, and a price the customer has decided is final.</p>
<p>An inquiry flow is a different mechanism from a checkout with the payment step removed. It needs a list that survives page loads, fields that map to what a quote actually requires, a stored record that doesn't depend on an email arriving, and one screen where somebody can read the whole thing.</p>
<h2>Take the cart out of the loop</h2>
<p>WooCommerce will keep offering add-to-cart until you remove it. Kill the template hooks and make products unpurchasable, so nothing can sneak into the cart through a leftover widget or an old link.</p>
<pre><code class="language-php">&lt;?php
/**
 * Plugin Name: Inquiry list instead of cart
 */

add_action( 'init', function () {
    remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_add_to_cart', 10 );
    remove_action( 'woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30 );
} );

// Nothing is purchasable, so no code path can add to cart.
add_filter( 'woocommerce_is_purchasable', '__return_false' );
</code></pre>
<p>One consequence people miss: with <code>woocommerce_is_purchasable</code> false, the product schema loses its <code>offers</code> block. If you were relying on price in the structured data, it's gone, and that's the correct outcome — you no longer sell at that price directly. Decide whether you still want the product markup at all.</p>
<h2>Where the inquiry list lives</h2>
<p>The list has to survive navigation without a login. The WooCommerce session is already doing that job for the cart: a cookie identifies the visitor, the contents sit in the database, and it's available on every front-end request.</p>
<pre><code class="language-php">function aw_inquiry_list() {
    if ( null === WC()-&gt;session ) {
        WC()-&gt;initialize_session();
    }
    return (array) WC()-&gt;session-&gt;get( 'aw_inquiry', array() );
}

add_action( 'wp_ajax_aw_inquiry_add', 'aw_inquiry_add' );
add_action( 'wp_ajax_nopriv_aw_inquiry_add', 'aw_inquiry_add' );
function aw_inquiry_add() {
    check_ajax_referer( 'aw_inquiry', 'nonce' );

    $id  = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
    $qty = isset( $_POST['qty'] ) ? max( 1, absint( $_POST['qty'] ) ) : 1;

    if ( ! $id || 'product' !== get_post_type( $id ) ) {
        wp_send_json_error( array( 'message' =&gt; 'Unknown product' ), 400 );
    }

    $list         = aw_inquiry_list();
    $list[ $id ]  = $qty;
    WC()-&gt;session-&gt;set( 'aw_inquiry', $list );

    wp_send_json_success( array( 'count' =&gt; count( $list ) ) );
}
</code></pre>
<p>The button replaces the add-to-cart template in both the loop and the single product page:</p>
<pre><code class="language-php">function aw_inquiry_loop_button() {
    global $product;
    printf(
        '&lt;button type="button" class="button aw-inquiry-add" data-id="%d"&gt;%s&lt;/button&gt;',
        absint( $product-&gt;get_id() ),
        esc_html__( 'Add to inquiry', 'aw' )
    );
}
add_action( 'woocommerce_after_shop_loop_item', 'aw_inquiry_loop_button', 10 );
add_action( 'woocommerce_single_product_summary', 'aw_inquiry_loop_button', 30 );
</code></pre>
<p>And the client side, printed in the footer. <code>credentials: 'same-origin'</code> is not optional — without it the session cookie isn't sent on the fetch and every visitor gets an empty list.</p>
<pre><code class="language-php">add_action( 'wp_footer', function () {
    $config = wp_json_encode( array(
        'ajax'  =&gt; admin_url( 'admin-ajax.php' ),
        'nonce' =&gt; wp_create_nonce( 'aw_inquiry' ),
    ) );
    ?&gt;
    &lt;script&gt;
    window.awInquiry = &lt;?php echo $config; ?&gt;;
    document.addEventListener('click', function (e) {
      var btn = e.target.closest('.aw-inquiry-add');
      if (!btn) return;
      e.preventDefault();
      var body = new URLSearchParams();
      body.append('action', 'aw_inquiry_add');
      body.append('nonce', window.awInquiry.nonce);
      body.append('id', btn.dataset.id);
      fetch(window.awInquiry.ajax, { method: 'POST', credentials: 'same-origin', body: body })
        .then(function (r) { return r.json(); })
        .then(function (res) {
          if (res.success) { btn.textContent = 'Added'; btn.disabled = true; }
        });
    });
    &lt;/script&gt;
    &lt;?php
} );
</code></pre>
<h2>Fields that map to a quote</h2>
<p>Every field you add costs completions. The test for keeping one is whether the salesperson would have to send an email to ask for it before quoting.</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Why the quote needs it</th>
<th>Leave it out when</th>
</tr>
</thead>
<tbody><tr>
<td>Company</td>
<td>Determines whether it's a trade inquiry or a consumer one, changes the price</td>
<td>You sell identical items to everyone</td>
</tr>
<tr>
<td>Country</td>
<td>Freight, tax, certification, lead time</td>
<td>Domestic-only catalogue</td>
</tr>
<tr>
<td>Work email</td>
<td>The reply has a price on it</td>
<td>Consumer-facing</td>
</tr>
<tr>
<td>Application or use case</td>
<td>The only thing that separates two similar part numbers</td>
<td>Single-product catalogues</td>
</tr>
<tr>
<td>Target volume</td>
<td>Drives tier pricing and whether it's worth quoting</td>
<td>One-off items</td>
</tr>
<tr>
<td>Required date</td>
<td>Tells you whether to answer at all</td>
<td>Never — this is the one to keep</td>
</tr>
<tr>
<td>Product + quantity</td>
<td>The whole point</td>
<td>Never</td>
</tr>
</tbody></table>
<p>Free text for the application beats a dropdown of ten options, because the customer who types three lines about their environment is the customer worth calling back. Keep the reference fields as structured data and let everything else be prose.</p>
<p>Variable products are the awkward case. For an inquiry, store the parent product ID and let the customer describe the variant in the free-text field, or store the chosen attributes as a separate meta value. Do not try to reuse the variation lookup — the customer is asking, not ordering, and they will describe variants you don't have.</p>
<h2>Store the record first, send the email second</h2>
<p>Email fails. It lands in spam, the relay has a bad afternoon, the salesperson is on leave. If the inquiry only exists as an email, it's gone. Insert a post, then send.</p>
<pre><code class="language-php">add_action( 'admin_post_nopriv_aw_inquiry_submit', 'aw_inquiry_submit' );
add_action( 'admin_post_aw_inquiry_submit', 'aw_inquiry_submit' );
function aw_inquiry_submit() {
    check_admin_referer( 'aw_inquiry_submit' );

    // Honeypot: a real visitor never fills a hidden field.
    if ( ! empty( $_POST['aw']['website'] ) ) {
        wp_safe_redirect( home_url( '/thank-you/' ) );
        exit;
    }

    $in    = isset( $_POST['aw'] ) &amp;&amp; is_array( $_POST['aw'] ) ? wp_unslash( $_POST['aw'] ) : array();
    $field = function ( $key ) use ( $in ) {
        return isset( $in[ $key ] ) ? sanitize_text_field( $in[ $key ] ) : '';
    };

    $email = sanitize_email( $field( 'email' ) );
    if ( ! is_email( $email ) ) {
        wp_die( 'Please enter a valid email address.', 'Inquiry', array( 'response' =&gt; 400 ) );
    }

    $qty   = isset( $_POST['qty'] ) &amp;&amp; is_array( $_POST['qty'] ) ? array_map( 'absint', wp_unslash( $_POST['qty'] ) ) : array();
    $lines = array();
    foreach ( aw_inquiry_list() as $id =&gt; $default ) {
        $product = wc_get_product( $id );
        if ( ! $product ) {
            continue;
        }
        $lines[] = array(
            'id'   =&gt; (int) $id,
            'name' =&gt; $product-&gt;get_name(),
            'sku'  =&gt; $product-&gt;get_sku(),
            'qty'  =&gt; isset( $qty[ $id ] ) ? max( 1, (int) $qty[ $id ] ) : 1,
        );
    }
    if ( ! $lines ) {
        wp_die( 'The inquiry list is empty.', 'Inquiry', array( 'response' =&gt; 400 ) );
    }

    $post_id = wp_insert_post( array(
        'post_type'   =&gt; 'aw_inquiry',
        'post_status' =&gt; 'private',
        'post_title'  =&gt; $field( 'company' ) . ' - ' . current_time( 'Y-m-d H:i' ),
        'meta_input'  =&gt; array(
            'aw_name'        =&gt; $field( 'name' ),
            'aw_company'     =&gt; $field( 'company' ),
            'aw_email'       =&gt; $email,
            'aw_country'     =&gt; $field( 'country' ),
            'aw_application' =&gt; $field( 'application' ),
            'aw_volume'      =&gt; $field( 'volume' ),
            'aw_needed_by'   =&gt; $field( 'needed_by' ),
            'aw_lines'       =&gt; $lines,
        ),
    ), true );

    if ( is_wp_error( $post_id ) ) {
        wp_die( 'Could not save the inquiry.', 'Inquiry', array( 'response' =&gt; 500 ) );
    }

    $body = sprintf(
        "Name: %s\nCompany: %s\nEmail: %s\nCountry: %s\n\n",
        $field( 'name' ), $field( 'company' ), $email, $field( 'country' )
    );
    foreach ( $lines as $line ) {
        $body .= sprintf( "- %s (%s) x %d\n", $line['name'], $line['sku'], $line['qty'] );
    }
    $body .= sprintf(
        "\nApplication: %s\nVolume: %s\nNeeded by: %s\n\nEdit: %s",
        $field( 'application' ), $field( 'volume' ), $field( 'needed_by' ),
        admin_url( 'post.php?post=' . $post_id . '&amp;action=edit' )
    );

    wp_mail( get_option( 'admin_email' ), 'Inquiry: ' . $field( 'company' ), $body );
    wp_mail( $email, 'We received your inquiry', "Thanks, we will reply within one business day.\n" );

    WC()-&gt;session-&gt;set( 'aw_inquiry', array() );

    wp_safe_redirect( home_url( '/thank-you/' ) );
    exit;
}
</code></pre>
<p>Send from an address on your own domain, not <code>admin_email</code> on a shared host. PHP's mail function leaving from a domain without SPF and DKIM gets filtered often enough that the auto-reply is the first thing to disappear. Route it through an SMTP plugin or your host's relay and send yourself one test before launch.</p>
<h2>Reading it in the admin</h2>
<p>The record needs a screen. A private custom post type gives you the list view, search, and the permalink you put in the notification email.</p>
<pre><code class="language-php">add_action( 'init', function () {
    register_post_type( 'aw_inquiry', array(
        'label'     =&gt; 'Inquiries',
        'public'    =&gt; false,
        'show_ui'   =&gt; true,
        'supports'  =&gt; array( 'title' ),
        'menu_icon' =&gt; 'dashicons-email-alt',
    ) );
} );

add_filter( 'manage_aw_inquiry_posts_columns', function ( $cols ) {
    return array(
        'cb'      =&gt; $cols['cb'],
        'title'   =&gt; 'Inquiry',
        'company' =&gt; 'Company',
        'email'   =&gt; 'Email',
        'country' =&gt; 'Country',
        'items'   =&gt; 'Items',
        'date'    =&gt; 'Date',
    );
} );

add_action( 'manage_aw_inquiry_posts_custom_column', function ( $col, $post_id ) {
    if ( 'items' === $col ) {
        $lines = get_post_meta( $post_id, 'aw_lines', true );
        echo is_array( $lines ) ? count( $lines ) : 0;
        return;
    }
    if ( in_array( $col, array( 'company', 'email', 'country' ), true ) ) {
        echo esc_html( get_post_meta( $post_id, 'aw_' . $col, true ) );
    }
}, 10, 2 );

add_action( 'add_meta_boxes', function () {
    add_meta_box( 'aw_inquiry_lines', 'Requested items', function ( $post ) {
        $lines = get_post_meta( $post-&gt;ID, 'aw_lines', true );
        if ( ! is_array( $lines ) || ! $lines ) {
            echo '&lt;p&gt;No items.&lt;/p&gt;';
            return;
        }
        echo '&lt;table class="widefat"&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Product&lt;/th&gt;&lt;th&gt;SKU&lt;/th&gt;&lt;th&gt;Qty&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;';
        foreach ( $lines as $line ) {
            printf(
                '&lt;tr&gt;&lt;td&gt;%s&lt;/td&gt;&lt;td&gt;%s&lt;/td&gt;&lt;td&gt;%d&lt;/td&gt;&lt;/tr&gt;',
                esc_html( $line['name'] ), esc_html( $line['sku'] ), (int) $line['qty']
            );
        }
        echo '&lt;/tbody&gt;&lt;/table&gt;';
    }, 'aw_inquiry', 'normal', 'high' );
} );
</code></pre>
<p>The form itself is a shortcode on a normal page, with the list rendered server-side so the customer sees what they picked, quantity fields they can correct, and the honeypot field hidden off-screen rather than with <code>display:none</code> — some bots skip hidden inputs.</p>
<h2>What breaks after you ship it</h2>
<p>The inquiry page cannot be behind a full-page cache. The list is rendered from the session, and the nonce expires; a cached copy hands the second visitor a stale nonce and the submission bounces with a <code>-1</code>. Exclude that page from caching, and if you use a CDN, exclude it there too.</p>
<p>A <code>400</code> on submit with no visible error is almost always the nonce, and the nonce is almost always the cache.</p>
<p>Sessions expire. A customer who builds a list on Monday and submits on Thursday gets an empty inquiry. If the product matters to you, mirror the IDs into a cookie with a long lifetime and merge it back on read, rather than extending the WooCommerce session for everyone.</p>
<p>Quantity fields submitted without matching product IDs fail silently if you don't check <code>get_post_type</code> in the handler — the customer can post any integer they like as a product ID, so treat every ID from the request as untrusted.</p>
<p>Last, decide who reads the record. A private post type that nobody opens is the same as no record at all; either point the notification at a shared mailbox the team actually monitors, or agree on a weekly sweep of the admin list.</p>
<p>If the inquiry flow needs to go past a list and a form — variant configurators, file uploads per line item, routing to different salespeople by country — that is the kind of thing we build as custom work, and the starting point is <a href="https://aumcreate.com/services?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-woo-inquiry-cart-notification">the services page</a>.</p>
]]></content:encoded></item><item><title><![CDATA[An AI crawler is making your server slow: confirm it from the log, then rate-limit that one bot]]></title><description><![CDATA[Traffic climbs overnight, PHP-FPM workers sit at their limit, TTFB goes from a few hundred milliseconds to several seconds, and the database is doing…]]></description><link>https://aumcreate.hashnode.dev/an-ai-crawler-is-making-your-server-slow-confirm-it-from-the-log-then-rate-limit-that-one-bot</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/an-ai-crawler-is-making-your-server-slow-confirm-it-from-the-log-then-rate-limit-that-one-bot</guid><category><![CDATA[sysadmin]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Wed, 28 Oct 2026 03:26:15 GMT</pubDate><content:encoded><![CDATA[<p>Traffic climbs overnight, PHP-FPM workers sit at their limit, TTFB goes from a few hundred milliseconds to several seconds, and the database is doing work it wasn't doing last week. Nobody deployed anything. What did change is the referer field in your access log, which now carries a fair amount of <code>chatgpt.com</code> and <code>perplexity.ai</code>, and the concurrent-visitor number looks like an attack.</p>
<p>Treating crawler traffic as an attack throws away referrals you may want. The narrower job is to find which crawler is actually expensive, and slow that one down without touching the rest.</p>
<p>You need three things from the log before you change a single line of config: which user agent is responsible, how much origin time it accounts for, and which URLs it keeps hitting. Rate of requests alone tells you very little, because a bot fetching cached HTML is close to free while a bot walking a faceted product filter is not.</p>
<h2>Log the fields you need, not the ones that look tidy</h2>
<p>The combined log format has bytes and status but no timing, so it cannot answer the question. Add a JSON format that carries <code>$request_time</code>, <code>$upstream_response_time</code> and the user agent. <code>$request_time</code> includes the time spent pushing bytes to a slow client; <code>$upstream_response_time</code> is the part PHP and the database are responsible for, which is what you're chasing.</p>
<pre><code class="language-nginx"># http context
log_format bot escape=json '{"ip":"$remote_addr","t":"$time_iso8601","req":"$request","status":$status,"rt":$request_time,"urt":"$upstream_response_time","ua":"$http_user_agent","ref":"$http_referer"}';
access_log /var/log/nginx/bot.json bot;
</code></pre>
<p>Reload, let it collect for a full day, and make sure the window covers a busy period. A sample taken over a quiet afternoon will name the wrong crawler.</p>
<h2>Total origin seconds per user agent</h2>
<p>Request counts sort bots by how chatty they are. Sorting by accumulated upstream time sorts them by how much they cost.</p>
<pre><code class="language-bash">jq -r '[.ua, .urt] | @tsv' /var/log/nginx/bot.json \
  | awk -F'\t' '{ t[$1] += $2; c[$1]++ }
                END { for (u in t) printf "%9.1f  %7d  %8.2f  %s\n", t[u], c[u], t[u]/c[u], u }' \
  | sort -rn | head -20
</code></pre>
<p>Columns are total upstream seconds, request count, average seconds per request, and the user agent. Example shape of the output:</p>
<pre><code>   1842.6    18221     0.10  Mozilla/5.0 AppleWebKit/537.36 ... GPTBot/1.1
    301.4      4102     0.07  Mozilla/5.0 ... ClaudeBot/1.0
     88.9     31204     0.00  Mozilla/5.0 ... SomeMonitoringService
</code></pre>
<p>That third line is the one people misread. Thirty thousand requests and almost no cost means those requests are being served from cache or returning a fast static response. Leave it alone.</p>
<p>Total for the window, so you know what share the top bot holds:</p>
<pre><code class="language-bash">jq -r '.urt // 0' /var/log/nginx/bot.json | awk '{ s += $1 } END { print s " upstream seconds total" }'
</code></pre>
<h2>See what the expensive bot is asking for</h2>
<p>A high average cost usually has a boring explanation: the bot is walking pagination, filter query strings, or a search endpoint.</p>
<pre><code class="language-bash">jq -r 'select(.ua | test("gptbot"; "i")) | .req' /var/log/nginx/bot.json \
  | sort | uniq -c | sort -rn | head -20
</code></pre>
<p>If the top URLs are product pages that render from cache, the cost is somewhere else. If they are <code>/?s=</code>, <code>?filter_</code>, or <code>?orderby=</code>, every one of those is a fresh database query and you have found the leak. Those URLs should be blocked to all crawlers in <code>robots.txt</code> regardless of which bot is fetching them, because they are both expensive and useless as an indexed page.</p>
<p>Check what status codes the bot receives while you're there. A crawler that keeps retrying 404s and 500s is generating load for nothing:</p>
<pre><code class="language-bash">jq -r 'select(.ua | test("gptbot"; "i")) | .status' /var/log/nginx/bot.json \
  | sort | uniq -c | sort -rn
</code></pre>
<h2>Deciding which crawler to limit first</h2>
<p>Work down the list from the top of the seconds table, and skip anything in the left column.</p>
<table>
<thead>
<tr>
<th>Skip it</th>
<th>Limit it</th>
</tr>
</thead>
<tbody><tr>
<td>Its referrals land on pages that convert for you</td>
<td>Its referral traffic is negligible or never converts</td>
</tr>
<tr>
<td>It is hitting cached static pages</td>
<td>It is hitting uncached, parameterised, or search URLs</td>
</tr>
<tr>
<td>Its average cost per request is near zero</td>
<td>Its average cost per request is ten times the site average</td>
</tr>
<tr>
<td>Something on the site depends on it (indexing, a feed you publish)</td>
<td>Nothing on the site depends on it</td>
</tr>
</tbody></table>
<p>The rule in one line: highest total upstream seconds, among the crawlers whose traffic you can afford to slow. That is usually the one at the top of the table that isn't your search engine of record.</p>
<p>Don't try to solve this by adding CPU. The same crawler will find the same slow URLs on the bigger machine.</p>
<h2>Rate-limit by user agent, on dynamic locations only</h2>
<p>Start with <code>robots.txt</code>. It costs nothing and the well-behaved crawlers honour it, though not all of them honour <code>Crawl-delay</code> specifically. Check the log after a day and see whether the rate actually dropped; the log will tell you which bots read it.</p>
<pre><code>User-agent: Bytespider
Crawl-delay: 10

User-agent: *
Disallow: /?s=
Disallow: /*?filter_
</code></pre>
<p>For the ones that ignore it, use nginx. Map the user agent to a key, then rate-limit on that key. Requests with an empty key are not counted against any zone, so every other visitor keeps the normal path.</p>
<pre><code class="language-nginx"># http context
map $http_user_agent $botkey {
    default              "";
    ~*gptbot             "gptbot";
    ~*claudebot          "claudebot";
    ~*perplexitybot      "perplexitybot";
    ~*bytespider         "bytespider";
    ~*amazonbot          "amazonbot";
    ~*(googlebot|bingbot) "";
}

limit_req_zone $botkey zone=botrate:10m rate=20r/m;

server {
    location / {
        limit_req        zone=botrate burst=5 nodelay;
        limit_req_status 429;
        # ... your existing fastcgi/php config
    }
}
</code></pre>
<p>Twenty requests a minute with a burst of five is an example, not a recommendation. Pick a number just under the current rate of the bot you're slowing, so the change is a step down and not a wall.</p>
<p>Two things to keep in mind. The limit sits in <code>location /</code>, so static assets served from their own locations are unaffected — that's intentional, they were never the problem. And a <code>429</code> still costs a connection and a worker slot. It's far cheaper than rendering a filtered product page, but it isn't free, so a very aggressive rate limit with a large burst doesn't help as much as people expect.</p>
<p>If you'd rather block: <code>return 403</code> is the same mechanism and a bigger decision. When you block a crawler outright, whatever surface it feeds stops including you. Rate limiting keeps you in the index while keeping the server alive, which is usually the trade you want.</p>
<h2>Verify, then leave it alone</h2>
<p>After a full day, count the throttled responses by user agent:</p>
<pre><code class="language-bash">jq -r 'select(.status == 429) | .ua' /var/log/nginx/bot.json \
  | sort | uniq -c | sort -rn | head
</code></pre>
<p>Almost everything in that list should be bots. If human user agents show up, your key is too broad — the usual cause is a regex that matches a browser string, or a mobile carrier proxy that rewrites user agents into something that looks like a bot.</p>
<p>Then re-run the seconds-per-agent query for the same length of window and compare. You're looking for the top line's total to fall while the human traffic lines stay flat.</p>
<h2>Two things that will bite you</h2>
<p>User agents are self-declared strings. The bot that respects your config will send the real one; a scraper that wants to look like the bot will send the same one. If the rule starts charging a bot's traffic to random scrapers, the fix is not a better regex — it's forward-confirmed reverse DNS on the IP, or an allowlist of the operator's published address ranges, checked before the rate limit.</p>
<p>And make sure your own monitoring and uptime checks are excluded before you turn this on. There is nothing quite like rate-limiting your own health check and discovering it during the next incident.</p>
<p>If you want the per-crawler allow/deny without hand-maintaining the <code>map</code> block, that is what we built <a href="https://aumcreate.com/plugins/aumcrawl?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-ai-crawler-server-load-limit">AumCrawl</a> for: it records which AI crawlers are hitting the site and lets you allow or block each one separately.</p>
]]></content:encoded></item><item><title><![CDATA[Multisite network vs. one site with a translation layer: the four differences that actually cost you]]></title><description><![CDATA[Both approaches give you /es/, es.example.com or example.es. The URL shape is the least interesting part.]]></description><link>https://aumcreate.hashnode.dev/multisite-network-vs-one-site-with-a-translation-layer-the-four-differences-that-actually-cost-you</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/multisite-network-vs-one-site-with-a-translation-layer-the-four-differences-that-actually-cost-you</guid><category><![CDATA[architecture]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Tue, 27 Oct 2026 04:08:08 GMT</pubDate><content:encoded><![CDATA[<p>Both approaches give you <code>/es/</code>, <code>es.example.com</code> or <code>example.es</code>. The URL shape is the least interesting part. What decides the project is data isolation, plugin assumptions, hreflang plumbing, and how much work a single language rollback costs. This walks through each one with commands you can run on a staging copy.</p>
<h2>1. Pick the URL shape, then live with what it forces</h2>
<table>
<thead>
<tr>
<th>Shape</th>
<th>DNS / TLS</th>
<th>Where translations live</th>
<th>Rollback of one language</th>
</tr>
</thead>
<tbody><tr>
<td><code>example.com/es/</code> on one site + translation layer</td>
<td>nothing extra</td>
<td>rows in the same tables, filtered by language</td>
<td>delete rows / unpublish</td>
</tr>
<tr>
<td><code>example.com/es/</code> as a subdirectory multisite</td>
<td>nothing extra</td>
<td>separate <code>wp_2_posts</code>, <code>wp_2_options</code> per site</td>
<td><code>wp site delete</code> (content goes with it)</td>
</tr>
<tr>
<td><code>es.example.com</code> (subdomain multisite or separate installs)</td>
<td>wildcard DNS + wildcard or SAN certificate</td>
<td>separate tables per site</td>
<td>delete the site or the vhost</td>
</tr>
<tr>
<td><code>example.es</code></td>
<td>separate DNS zone + certificate per domain</td>
<td>separate tables per site, domain mapping</td>
<td>delete the site and the zone</td>
</tr>
</tbody></table>
<p>Two facts worth stating plainly, because they drive most of the cost:</p>
<ul>
<li>Subdirectory shapes keep every URL on one hostname. Nothing to provision, and every internal link, sitemap entry and canonical stays on a single origin.</li>
<li>Subdomain and per-domain shapes require DNS and TLS work per language, plus a mapping layer inside WordPress if you use one network. Budget that before you promise a launch date.</li>
</ul>
<p>If you have no operational reason to split hostnames, subdirectory is the cheaper shape in both models.</p>
<h2>2. What multisite actually shares</h2>
<p>Multisite is not "separate WordPress installs". It is one install, one user table, one plugin directory, N content tables.</p>
<p>Shared across the network:</p>
<ul>
<li><code>wp_users</code>, <code>wp_usermeta</code> — one account, roles assigned per site</li>
<li><code>wp_blogs</code>, <code>wp_site</code>, <code>wp_sitemeta</code> — the network itself</li>
<li><code>wp-content/plugins</code>, <code>wp-content/themes</code>, <code>wp-content/mu-plugins</code></li>
</ul>
<p>Per site (<code>N</code> = blog ID):</p>
<ul>
<li><code>wp_N_posts</code>, <code>wp_N_postmeta</code>, <code>wp_N_options</code>, <code>wp_N_terms</code>, <code>wp_N_term_relationships</code>, <code>wp_N_comments</code></li>
<li>uploads under <code>wp-content/uploads/sites/N/</code></li>
</ul>
<p>Verify it on a network with at least two sites:</p>
<pre><code class="language-bash"># tables that belong to blog 2 only
wp db query "SELECT TABLE_NAME FROM information_schema.TABLES
  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'wp\_2\_%'
  ORDER BY TABLE_NAME;"

# users are shared: same IDs from either site
wp user list --url=https://example.com --fields=ID,user_login,roles
wp user list --url=https://example.com/es --fields=ID,user_login,roles
</code></pre>
<p>A translation layer gives you the opposite trade: one <code>wp_posts</code> table containing every language, every query filtered by language, and every template that runs a raw <code>WP_Query</code> is now a place where the wrong language can leak in as a fallback. Neither model is "correct" — but the failure modes are different, and you should pick the one whose failure you can debug at 2am.</p>
<p>The real advantage of multisite is <code>wp_N_options</code>: separate menus, separate front page, separate plugin settings, separate permalink structure per language. If your languages will diverge structurally, that is the deciding factor. If they will stay literal translations of each other, a shared table with a language filter is less machinery.</p>
<h2>3. Plugin compatibility: the three failure modes</h2>
<p>Before you commit, audit every plugin you intend to keep.</p>
<p><strong>Hardcoded table prefix.</strong> Any raw SQL touching <code>wp_</code> instead of <code>$wpdb-&gt;prefix</code> or <code>$wpdb-&gt;base_prefix</code> will read and write site 1's data from site 2.</p>
<pre><code class="language-bash">grep -rnE "(FROM|INTO|UPDATE|JOIN)[[:space:]]+wp_" \
  wp-content/plugins/your-plugin --include='*.php'
</code></pre>
<p>Zero hits is the expectation. Hits are a blocker, not a warning.</p>
<p><strong>Settings stored in the wrong scope.</strong> A license key or API credential written with <code>get_option()</code> becomes per-site, and you re-enter it on every language. That should be <code>get_site_option()</code> / <code>update_site_option()</code>.</p>
<pre><code class="language-bash">grep -rn "get_site_option\|update_site_option\|get_option(\|update_option(" \
  wp-content/plugins/your-plugin --include='*.php' | head -40
</code></pre>
<p>You are looking for which settings are network-wide in intent. This is a manual read, not a grep verdict.</p>
<p><strong>Tables created only on activation.</strong> Custom tables are usually created in an activation hook, which fires once — for the site that activated it. Site 2 gets no table and throws database errors.</p>
<pre><code class="language-bash"># activate the plugin on site 2, load its admin screen, then:
wp db query "SHOW TABLES LIKE '%yourplugin%';"
</code></pre>
<p>If only <code>wp_yourplugin...</code> exists and <code>wp_2_yourplugin...</code> is missing, the plugin is not multisite-safe without a workaround.</p>
<h2>4. Converting to subdirectory multisite, in order</h2>
<p>Do this on staging first. The conversion is not a two-way door.</p>
<pre><code class="language-bash"># 0. backup
wp db export pre-multisite.sql

# 1. deactivate everything (the converter requires it)
wp plugin deactivate --all

# 2. convert — the site must be installed at the domain root
wp core multisite-convert --title="Example Network"
</code></pre>
<p>The command prints the constants to paste into <code>wp-config.php</code>. They look like this:</p>
<pre><code class="language-php">// wp-config.php — above the "That's all, stop editing" line
define( 'WP_ALLOW_MULTISITE', true );
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', false );   // true for es.example.com

define( 'DOMAIN_CURRENT_SITE', 'example.com' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );
</code></pre>
<p>Then replace the WordPress block in <code>.htaccess</code> with the subdirectory rules:</p>
<pre><code class="language-apache">RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]

# add a trailing slash to /wp-admin
RewriteRule ^([_0-9a-zA-Z-]+/)?wp-admin$ $1wp-admin/ [R=301,L]

RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L]
RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ $2 [L]
RewriteRule . index.php [L]
</code></pre>
<p>Add the language site and check it responds:</p>
<pre><code class="language-bash">wp site create --slug=es --title="Example ES" --email=admin@example.com
wp option update WPLANG es_ES --url=https://example.com/es
wp rewrite flush --url=https://example.com/es

curl -sI https://example.com/es/ | head -1
</code></pre>
<p>Common breakages at this step: the site was not installed at the domain root, a page with the slug <code>es</code> already exists and now shadows the new site, or <code>WPLANG</code> is set before the language files are installed so the site silently stays in English.</p>
<h2>5. hreflang across a network</h2>
<p>A translation layer usually emits alternates for you. A network does not — each site thinks it is alone. Check first:</p>
<pre><code class="language-bash">curl -sL https://example.com/es/pagina/ | grep -c 'rel="alternate" hreflang='
</code></pre>
<p><code>0</code> means you are serving translated pages with no cluster at all. Relate translations with a shared meta value, then emit the full set from one place:</p>
<pre><code class="language-php">&lt;?php
// wp-content/mu-plugins/network-hreflang.php
// Requires a shared _translation_group meta value on each translated post.
add_action( 'wp_head', function () {
    if ( ! is_multisite() || ! is_singular() ) {
        return;
    }

    $post_id = get_queried_object_id();
    $group   = get_post_meta( $post_id, '_translation_group', true );
    if ( ! $group ) {
        return;
    }

    $post_type = get_post_type( $post_id );
    $links     = [];

    foreach ( get_sites( [ 'number' =&gt; 0 ] ) as $site ) {
        switch_to_blog( (int) $site-&gt;blog_id );

        $found = get_posts( [
            'post_type'      =&gt; $post_type,
            'post_status'    =&gt; 'publish',
            'meta_key'       =&gt; '_translation_group',
            'meta_value'     =&gt; $group,
            'posts_per_page' =&gt; 1,
            'fields'         =&gt; 'ids',
            'no_found_rows'  =&gt; true,
        ] );

        if ( $found ) {
            $links[ get_locale() ] = get_permalink( $found[0] );
        }

        restore_current_blog();
    }

    if ( count( $links ) &lt; 2 ) {
        return;
    }

    foreach ( $links as $locale =&gt; $url ) {
        printf(
            '&lt;link rel="alternate" hreflang="%s" href="%s" /&gt;' . "\n",
            esc_attr( str_replace( '_', '-', $locale ) ),
            esc_url( $url )
        );
    }
}, 5 );
</code></pre>
<p>Notes that matter in production:</p>
<ul>
<li>The set must include the current page itself, and every target must be a canonical URL. If site 2 redirects <code>www</code> to non-<code>www</code>, emit the final URL, not the configured <code>home_url()</code> blindly.</li>
<li><code>switch_to_blog()</code> is a context switch with query cost. With more than a handful of sites, cache <code>$links</code> in a transient keyed by <code>$group</code> and delete it on <code>save_post</code>.</li>
<li>Verify per page, not once: <code>curl -sL &lt;url&gt; | grep -o 'hreflang="[^"]*"'</code> should list one entry per language that actually has a translation.</li>
</ul>
<h2>6. Maintenance cost, side by side</h2>
<table>
<thead>
<tr>
<th>Task</th>
<th>One site + translation layer</th>
<th>Multisite network</th>
</tr>
</thead>
<tbody><tr>
<td>Core / plugin update</td>
<td>one update, one blast radius</td>
<td>one network update, blast radius = every language</td>
</tr>
<tr>
<td>Backup</td>
<td>one database, one uploads tree</td>
<td>must include <code>wp_blogs</code>, <code>wp_site</code>, <code>wp_sitemeta</code> <strong>and</strong> every <code>wp_N_*</code> table plus <code>uploads/sites/N</code></td>
</tr>
<tr>
<td>Staging refresh</td>
<td>copy and rewrite two URLs</td>
<td>copy all tables, then rewrite <code>siteurl</code> / <code>home</code> in each <code>wp_N_options</code></td>
</tr>
<tr>
<td>Adding a language</td>
<td>install language files, duplicate content</td>
<td><code>wp site create</code>, install language files, duplicate templates and menus</td>
</tr>
<tr>
<td>Removing a language</td>
<td>unpublish / delete rows</td>
<td><code>wp site delete</code> — irreversible for that content</td>
</tr>
<tr>
<td>Per-language settings</td>
<td>often globally shared, sometimes faked</td>
<td>genuinely separate per site</td>
</tr>
<tr>
<td>User management</td>
<td>shared roles, needs custom logic for per-language roles</td>
<td>same account, role per site</td>
</tr>
</tbody></table>
<p>The backup row is where teams get caught. A network restored from a single-site dump comes back with the main site and nothing else.</p>
<h2>7. Decision checklist</h2>
<p>Answer these before choosing:</p>
<ol>
<li>Will the languages diverge structurally (different menus, different front pages, different plugins)? → multisite.</li>
<li>Is it one product catalog with translated strings? → usually a single site.</li>
<li>Do translators need their own admin accounts with scoped roles? → multisite handles this natively.</li>
<li>Can you back up and restore N tables and rewrite N sets of <code>siteurl</code>/<code>home</code>? If not, the network will eventually be restored wrong.</li>
<li>Do you need to kill a single language quickly without touching the others? → multisite, with the caveat that you lose its content.</li>
<li>Do you have DNS and TLS automation for subdomains or separate domains? If not, stay on a subdirectory shape.</li>
</ol>
<p>If you would rather not run the network yourself, we do WordPress deployment and custom builds where this decision is made up front: <a href="https://aumcreate.com/services?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-multisite-vs-single-multilang-decision">https://aumcreate.com/services?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-multisite-vs-single-multilang-decision</a></p>
]]></content:encoded></item><item><title><![CDATA[You already run an all-in-one SEO plugin — do you still need separate llms.txt and schema plugins?]]></title><description><![CDATA[Short answer: these are two separate decisions, and they land on opposite sides.]]></description><link>https://aumcreate.hashnode.dev/you-already-run-an-all-in-one-seo-plugin-do-you-still-need-separate-llms-txt-and-schema-plugins</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/you-already-run-an-all-in-one-seo-plugin-do-you-still-need-separate-llms-txt-and-schema-plugins</guid><category><![CDATA[JSON-LD]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Tue, 27 Oct 2026 04:03:20 GMT</pubDate><content:encoded><![CDATA[<p>Short answer: these are two separate decisions, and they land on opposite sides.</p>
<ul>
<li><strong>llms.txt</strong> — close to zero functional overlap with an all-in-one SEO plugin. The output is one text file. The open question is whether AI crawlers actually consume it, not whether it collides with something you already run.</li>
<li><strong>Schema / JSON-LD</strong> — heavy overlap. A second plugin that emits structured data is worth it only when you can name the exact entity type your current setup does not emit, <em>and</em> you can switch the equivalent module off in the current one.</li>
</ul>
<p>Below is the check sequence that turns that into a decision for your site.</p>
<h2>What actually overlaps</h2>
<table>
<thead>
<tr>
<th>Output</th>
<th>Typical all-in-one SEO plugin</th>
<th>What a second plugin usually adds</th>
<th>What happens if both stay on</th>
</tr>
</thead>
<tbody><tr>
<td>title, meta description, canonical, robots meta</td>
<td>yes</td>
<td>nothing new</td>
<td>duplicate tags; which one a crawler picks is not under your control</td>
</tr>
<tr>
<td>XML sitemap</td>
<td>yes</td>
<td>per-post-type control, segmented files</td>
<td>two sitemaps submitted, coverage split</td>
</tr>
<tr>
<td>Organization / WebSite / BreadcrumbList</td>
<td>yes</td>
<td>different <code>@id</code> scheme</td>
<td>crawler sees two entities for one company</td>
</tr>
<tr>
<td>Article / BlogPosting</td>
<td>yes</td>
<td>richer <code>about</code> / <code>mentions</code></td>
<td>duplicated node, conflicting <code>dateModified</code></td>
</tr>
<tr>
<td>Product / Offer / Review</td>
<td>depends on the ecommerce integration</td>
<td>often the real reason people add a plugin</td>
<td>two <code>Offer</code> nodes for the same SKU</td>
</tr>
<tr>
<td>llms.txt</td>
<td>rarely</td>
<td>the file itself</td>
<td>none</td>
</tr>
<tr>
<td>per-bot rules for AI crawlers</td>
<td>rarely</td>
<td>—</td>
<td>none</td>
</tr>
</tbody></table>
<p>Two rules fall out of that table:</p>
<ol>
<li><strong>One owner per output.</strong> For each row, exactly one plugin is allowed to write it. Keep that list in the repo, not in someone's head.</li>
<li><strong>Disable before you enable.</strong> Never activate the new plugin first and plan to clean up afterwards.</li>
</ol>
<h2>Three checks, five minutes</h2>
<p>Run these against the page with the most schema on it — a product page, or a long article with FAQ blocks.</p>
<h3>1. Count and inspect the JSON-LD blocks</h3>
<pre><code class="language-bash">curl -sL https://example.com/sample-page/ \
  | python3 -c '
import sys, json, re
html = sys.stdin.read()
blocks = re.findall(r"&lt;script[^&gt;]+application/ld\+json[^&gt;]*&gt;(.*?)&lt;/script&gt;", html, re.S)
print(f"{len(blocks)} JSON-LD block(s)")
for b in blocks:
    try:
        data = json.loads(b)
    except json.JSONDecodeError as e:
        print("INVALID:", e); continue
    nodes = data if isinstance(data, list) else data.get("@graph", [data])
    for n in nodes:
        print(" ", n.get("@type"), "|", n.get("@id", ""))
'
</code></pre>
<p>How to read the output:</p>
<ul>
<li>The same <code>@type</code> twice with <strong>different</strong> <code>@id</code> values → two entities. This is the case that hurts: an <code>Organization</code> at <code>https://example.com/#organization</code> and another at <code>https://example.com#organization</code> are not merged.</li>
<li>The same <code>@type</code> twice with the <strong>same</strong> <code>@id</code> → merged, but check that the properties do not contradict each other (two different <code>logo</code> values, two different <code>telephone</code>).</li>
<li><code>INVALID</code> → a plugin is printing unescaped content into the script tag. Fix that before adding anything new.</li>
<li>More than one block is not automatically wrong. A single <code>@graph</code> and several separate blocks are both fine, as long as no entity is declared twice.</li>
</ul>
<h3>2. Duplicate meta tags</h3>
<pre><code class="language-bash">curl -sL https://example.com/sample-page/ \
  | grep -oE '&lt;(meta|link)[^&gt;]*(description|canonical)[^&gt;]*&gt;'
</code></pre>
<p>Expect exactly one <code>description</code> and one <code>canonical</code>. Two of either means output ordering is already contested; a third writer makes it worse, not better.</p>
<h3>3. Is anything serving llms.txt today</h3>
<pre><code class="language-bash">curl -s -o /dev/null -w '%{http_code}\n' https://example.com/llms.txt
curl -s https://example.com/llms.txt | head -3
</code></pre>
<ul>
<li><code>404</code> → nothing there.</li>
<li><code>200</code> but the body starts with <code>&lt;!DOCTYPE</code> → your fallback handler is returning 200 for missing files. Treat it as missing; a text file is what crawlers parse.</li>
<li>Markdown starting with <code># </code> → already covered, leave it alone.</li>
</ul>
<p>While you are in there, check whether anything already talks to AI crawlers at all:</p>
<pre><code class="language-bash">curl -s https://example.com/robots.txt \
  | grep -iE 'gptbot|claudebot|perplexitybot|google-extended|ccbot'
</code></pre>
<h2>Decision rules</h2>
<p><strong>Add a second schema plugin only if all three are true:</strong></p>
<ol>
<li>You can name the entity type you are missing (for example <code>Product</code> with <code>Offer</code> variants, <code>Service</code>, <code>Event</code>). "Better schema" is not a gap description.</li>
<li>The current plugin lets you disable its schema module — globally, or at least per post type.</li>
<li>You have re-fetched the three test pages (home, single post, product/archive) and the entity count went <em>down</em> or stayed equal while the missing type appeared.</li>
</ol>
<p><strong>Do not add a second schema plugin if:</strong> the current one already emits the type, or you cannot disable its module, or the gap is really "my page builder prints its own JSON-LD too". Three emitters is worse than two.</p>
<p><strong>For llms.txt, the decision is not a vendor decision.</strong> The proposal is a community convention; no major search engine has committed to parsing it, so treat it as cheap hygiene rather than a ranking lever. Generating the file yourself is a legitimate option:</p>
<pre><code class="language-php">&lt;?php
// wp-content/mu-plugins/llms-txt.php — serves /llms.txt from a template file
add_action( 'init', function () {
    add_rewrite_rule( '^llms\.txt$', 'index.php?llms_txt=1', 'top' );
} );

add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'llms_txt';
    return $vars;
} );

add_action( 'template_redirect', function () {
    if ( ! get_query_var( 'llms_txt' ) ) {
        return;
    }
    header( 'Content-Type: text/plain; charset=utf-8' );
    $path = get_stylesheet_directory() . '/llms.txt';
    echo file_exists( $path ) ? file_get_contents( $path ) : '';
    exit;
} );
</code></pre>
<p>Flush rewrite rules once after adding it (<code>wp rewrite flush</code>), then confirm with the <code>curl</code> above that you get <code>text/plain</code>, not an HTML page.</p>
<h2>If you do add a second output plugin, order matters</h2>
<pre><code class="language-bash"># 1. snapshot what the site emits today
curl -sL https://example.com/sample-page/ \
  | grep -oE '&lt;(meta|link|script)[^&gt;]*&gt;' | sort &gt; before.txt

# 2. disable the overlapping module in the EXISTING plugin first
#    (its schema module / its sitemap module) — one at a time

# 3. re-fetch and diff
curl -sL https://example.com/sample-page/ \
  | grep -oE '&lt;(meta|link|script)[^&gt;]*&gt;' | sort &gt; after.txt
diff before.txt after.txt
</code></pre>
<p>If step 3 shows the schema disappearing entirely, the existing plugin was the only owner — good, now the new one can take over. If it shows <em>more</em> tags than before, stop: something is double-emitting.</p>
<h2>Maintenance cost, plainly</h2>
<ul>
<li>Every added plugin is another update surface and another place where a core or plugin release can change output ordering.</li>
<li>Two <code>Organization</code> nodes with mismatched <code>@id</code> values is the most common silent breakage, and it is invisible in the admin.</li>
<li>Two sitemaps means two submissions; pick one and remove the other.</li>
<li>Re-running the three checks above takes about three minutes. Put it on a monthly calendar rather than trusting that nothing changed.</li>
<li>Record the ownership table from the top of this post next to the site's deploy notes, so the next person does not "just add another plugin".</li>
</ul>
<h2>Short verdict list</h2>
<ul>
<li>Suite covers the schema you need, you only want <code>llms.txt</code> → do not add a second schema plugin; ship the file.</li>
<li>Suite emits generic schema, you need a specific type it cannot produce → add one, and disable the suite's schema module for that post type.</li>
<li>Page builder also emits JSON-LD → count first, then remove one emitter; the number of plugins is not the metric.</li>
<li>You want to know which AI crawlers actually request pages on your site → that is a log question, not a schema or <code>llms.txt</code> question.</li>
</ul>
<p>We build AumViso (free, on wordpress.org), which covers <code>llms.txt</code>, a schema graph, sitemap, meta and internal links — same category as the plugins discussed above, so judge it with the checks in this post rather than with a feature list: <a href="https://aumcreate.com/plugins/aumviso?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-seo-suite-llms-schema-overlap">https://aumcreate.com/plugins/aumviso?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-seo-suite-llms-schema-overlap</a></p>
<p>If your actual question is "which bots are hitting me and should I let them", that is a separate piece of plumbing: <a href="https://aumcreate.com/crawlers?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-seo-suite-llms-schema-overlap">https://aumcreate.com/crawlers?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-seo-suite-llms-schema-overlap</a></p>
]]></content:encoded></item><item><title><![CDATA[The minimum set of pages a company website needs — and what can wait]]></title><description><![CDATA[Six pages. That is the whole answer. A company website needs six pages before it needs anything else, and most sites that feel unfinished are not mis…]]></description><link>https://aumcreate.hashnode.dev/the-minimum-set-of-pages-a-company-website-needs-and-what-can-wait</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/the-minimum-set-of-pages-a-company-website-needs-and-what-can-wait</guid><category><![CDATA[WordPress]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Tue, 27 Oct 2026 03:57:05 GMT</pubDate><content:encoded><![CDATA[<p>Six pages. That is the whole answer. A company website needs six pages before it needs anything else, and most sites that feel unfinished are not missing a seventh page — they are missing the content on these six.</p>
<p>Here is what each one has to answer, what goes on it, and how to tell it is done.</p>
<h2>Page 1 — Home</h2>
<p><strong>The question it answers:</strong> <em>What is this, and is it for me?</em></p>
<p><strong>What goes on it:</strong></p>
<ul>
<li>One sentence, plain language, saying what you sell and to whom. Not a slogan. "We install commercial kitchen extraction systems for restaurants in the west of the country" is a sentence. "Engineering comfort" is not.</li>
<li>Three or four lines on what makes the offer concrete: what you actually deliver, in what timeframe, for what kind of customer.</li>
<li>Proof that a stranger can check — a real project photo, a client name you have permission to use, a location, a certification number.</li>
<li>One obvious next step, repeated at the top and the bottom.</li>
</ul>
<p><strong>What kills it:</strong> a carousel nobody watches, a stock photo of people shaking hands, no address, no indication of which country you are in, and a hero section that says nothing a competitor could not also say.</p>
<p><strong>Done test:</strong> show it to someone outside your industry for ten seconds and ask what the company does. If they cannot say it back in one sentence, rewrite the sentence, not the design.</p>
<h2>Page 2 — What you sell</h2>
<p><strong>The question:</strong> <em>What exactly am I buying, and in which version?</em></p>
<p>One page if you sell one thing. An index page plus a page per line if you sell several. Each item needs:</p>
<ul>
<li>what it is, in the customer's words, not the internal product name</li>
<li>who it is for and who it is not for</li>
<li>what is included, and — this matters more than most people think — what is explicitly not included</li>
<li>the variants, sizes, models, or service tiers, as a list or a table</li>
<li>one photo per variant, on a plain background, named in plain language</li>
</ul>
<p><strong>Done test:</strong> a customer can point at one line and say "that one". If they have to email you to ask which version they need, the page is not finished.</p>
<h2>Page 3 — What it costs</h2>
<p><strong>The question:</strong> <em>Can I afford this, roughly?</em></p>
<p>This is the page most small company sites refuse to write, and it is the reason most of them lose the enquiry. If you do not answer it, the visitor assumes you are expensive and goes somewhere that did.</p>
<p>Three acceptable forms, in order of preference:</p>
<ol>
<li><strong>Real prices</strong> with the unit stated. Fine for standardised products and fixed-scope services.</li>
<li><strong>A starting point.</strong> "From X" plus one sentence on what pushes it up.</li>
<li><strong>What drives the price.</strong> A short list — size, materials, quantity, access, timeline — plus the smallest and largest job you typically take.</li>
</ol>
<p>What is not acceptable: "contact us for a quote" and nothing else. That is not a price page, it is a form.</p>
<p><strong>Done test:</strong> a stranger can place themselves in a band — small, typical, large — without asking you.</p>
<h2>Page 4 — About</h2>
<p><strong>The question:</strong> <em>Who am I dealing with, and can I check them?</em></p>
<p>What goes on it:</p>
<ul>
<li>the legal name of the business, plus any trading name</li>
<li>the address you actually operate from — a real one</li>
<li>names and photos of the people a customer will deal with, if that is how you work</li>
<li>company registration or tax number, where your jurisdiction expects it published</li>
<li>how long you have been doing this — only if it is a fact you can evidence</li>
<li>the two or three things you do differently, stated as operations, not adjectives. "We answer email within one working day" is checkable. "We are passionate" is not.</li>
</ul>
<p><strong>Done test:</strong> someone can decide whether you are a real, findable business without leaving the page.</p>
<h2>Page 5 — Contact</h2>
<p><strong>The question:</strong> <em>How do I reach a human, and what happens after I do?</em></p>
<ul>
<li>a phone number that a person answers, with the hours and the timezone</li>
<li>an email address on your own domain — not a free mailbox</li>
<li>a form, as an addition, never as the only route</li>
<li>the physical address, again</li>
<li>one line on response time, and one line on what to include in the message so the first reply is useful: product, quantity, location, deadline</li>
</ul>
<p><strong>Done test:</strong> send yourself a message through every route on the page and confirm it arrives, and confirm the confirmation screen appears. A contact form that silently fails is the most expensive bug on any small site.</p>
<h2>Page 6 — The legal and trust layer</h2>
<p><strong>The question:</strong> <em>Is this site legitimate, and what do they do with my data?</em></p>
<p>Privacy policy, terms of sale or service, and — depending on where you are registered — an imprint or legal notice. If you set cookies beyond the strictly necessary, a cookie notice with a real reject option. Every one of these has to be reachable from every page, usually in the footer.</p>
<p><strong>Done test:</strong> there is a footer link to each, and each page names the legal entity and a contact route.</p>
<h2>The whole thing as a table</h2>
<table>
<thead>
<tr>
<th>Page</th>
<th>Question it answers</th>
<th>Minimum content</th>
<th>Can it wait?</th>
</tr>
</thead>
<tbody><tr>
<td>Home</td>
<td>What is this and is it for me?</td>
<td>One plain sentence, proof, one next step</td>
<td>No</td>
</tr>
<tr>
<td>What you sell</td>
<td>What exactly, which version?</td>
<td>Item list with variants and exclusions</td>
<td>No</td>
</tr>
<tr>
<td>Price</td>
<td>Can I afford it?</td>
<td>Real prices, a starting point, or price drivers</td>
<td>No</td>
</tr>
<tr>
<td>About</td>
<td>Who are you and can I check?</td>
<td>Legal name, address, people, registration</td>
<td>No</td>
</tr>
<tr>
<td>Contact</td>
<td>How do I reach a human?</td>
<td>Phone, email, form, response time</td>
<td>No</td>
</tr>
<tr>
<td>Legal</td>
<td>Is this legitimate?</td>
<td>Privacy, terms, imprint if required</td>
<td>No — it is a legal question, not a design one</td>
</tr>
</tbody></table>
<h2>What can wait — and the trigger for adding it</h2>
<p>Every page below is a real page with real value, and every one of them is a liability if it launches empty. The trigger column is the honest test.</p>
<table>
<thead>
<tr>
<th>Later page</th>
<th>Add it when</th>
</tr>
</thead>
<tbody><tr>
<td>Blog / news</td>
<td>You can commit to one useful post a month. An archive with two posts from three years ago argues against you.</td>
</tr>
<tr>
<td>Case studies</td>
<td>You have three finished projects and written permission to name them.</td>
</tr>
<tr>
<td>Testimonials page</td>
<td>You have quotes with a name and a role attached. Anonymous praise is decoration.</td>
</tr>
<tr>
<td>Team page</td>
<td>You have more than a handful of people, or hiring is a priority.</td>
</tr>
<tr>
<td>Careers</td>
<td>You have two or more open roles.</td>
</tr>
<tr>
<td>FAQ page</td>
<td>You keep answering the same three questions by email. Until then, fold them into the price page.</td>
</tr>
<tr>
<td>Newsletter</td>
<td>You will actually send it.</td>
</tr>
<tr>
<td>A second language</td>
<td>More than a small share of your enquiries arrive in that language.</td>
</tr>
<tr>
<td>Downloads / catalogue</td>
<td>Customers ask for the same PDF repeatedly.</td>
</tr>
</tbody></table>
<p>Notice that none of these triggers is "the site looks thin". Thin is a design opinion. Missing the price page is a lost enquiry.</p>
<h2>The five-question test for any page</h2>
<p>Before you write a seventh page, run it through this. If it fails two or more, do not build it.</p>
<ol>
<li>Does a real visitor arrive with a question this page answers?</li>
<li>Could a competitor paste the same text onto their site unchanged? If yes, it is not your page.</li>
<li>Is there anything on it that will still be true in a year?</li>
<li>Does it end with a next step?</li>
<li>Can you maintain it without a project?</li>
</ol>
<h2>Build order</h2>
<p><strong>Step 1 — Answer the questions in a text file, not a page builder.</strong> Six headings, plain text, no design. If you cannot write the Home sentence in this file, no layout will save it.</p>
<p><strong>Step 2 — Gather assets before you build.</strong> Logo in a vector format and a PNG, three to six real photographs, one product photo per variant on a plain background, the price list, the legal entity details. Missing assets are what turn a two-week build into a two-month one.</p>
<p><strong>Step 3 — Draft each page at 200–400 words.</strong> Shorter than you think. Long pages are usually a short page with the same idea repeated three ways.</p>
<p><strong>Step 4 — Build in the order above.</strong> Home, offer, price, about, contact, legal. Do not start on the design of the blog.</p>
<p><strong>Step 5 — Test before anyone sees it.</strong> Open every page on a phone. Submit every form and confirm the email arrives. Click every footer link. Check that every page has exactly one obvious next step and that it is not a dead end.</p>
<h2>Traps that cost the most</h2>
<ul>
<li><strong>The form-only contact page.</strong> People who are ready to buy want to call. A form is for people who are not.</li>
<li><strong>A price page that says "request a quote".</strong> It reads as "expensive" to anyone who does not already know you.</li>
<li><strong>An About page with no names, no address, no registration number.</strong> It reads as a hobby.</li>
<li><strong>A stock photo where a real one would do.</strong> A slightly imperfect photo of your actual workshop beats a perfect photo of someone else's.</li>
<li><strong>Filling the gaps with a blog.</strong> Three thin posts do not make a site look established; they make it look abandoned.</li>
</ul>
<p>Write the six. Ship the six. Add a seventh when a real visitor asks for it, not before.</p>
]]></content:encoded></item><item><title><![CDATA[Where to put the email capture form on a technical site, and what to send after someone submits it]]></title><description><![CDATA[Short answer: put the form where the reader has just finished getting something out of the page — the end of the article that solved their problem, o…]]></description><link>https://aumcreate.hashnode.dev/where-to-put-the-email-capture-form-on-a-technical-site-and-what-to-send-after-someone-submits-it</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/where-to-put-the-email-capture-form-on-a-technical-site-and-what-to-send-after-someone-submits-it</guid><category><![CDATA[WordPress]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Tue, 27 Oct 2026 03:35:39 GMT</pubDate><content:encoded><![CDATA[<p>Short answer: put the form where the reader has just finished getting something out of the page — the end of the article that solved their problem, or a page built around one downloadable artifact. Everything else is a safety net, not a strategy. And the placement decision is worth nothing until you have written the second email, because that is the one people actually judge you on.</p>
<p>Here is the whole thing as a process you can run in a week.</p>
<h2>Step 1 — Decide the list's single job (15 minutes, before any design)</h2>
<p>Write one sentence: "If you give me your email, I will send you ___ , roughly ___ often." If you cannot fill both blanks without hedging, stop here. A form without that sentence collects addresses that go cold in a month.</p>
<p>Two shapes work for a technical site:</p>
<ul>
<li><strong>The artifact list.</strong> One specific thing — a reference sheet, a checklist, a config template, a benchmark script. You send it immediately, then follow up with related material. High signup rate, small list, and it can stay small.</li>
<li><strong>The publication list.</strong> New long-form posts, sent when they exist. Lower signup rate, better retention, and it survives the day you run out of artifacts.</li>
</ul>
<p>Pick one. A list that mixes "here is your download" with "monthly newsletter" produces unsubscribes on email two, because the reader signed up for a transaction and got a subscription.</p>
<h2>Step 2 — Place the form where intent already exists</h2>
<p>Rank placements by what the reader is doing at that moment, not by how much space the layout has.</p>
<table>
<thead>
<tr>
<th>Placement</th>
<th>What the reader is doing</th>
<th>Promise that fits</th>
<th>Watch out for</th>
</tr>
</thead>
<tbody><tr>
<td>End of a long article, right after the conclusion</td>
<td>Just finished the problem</td>
<td>the next artifact that continues it</td>
<td>must be the same topic; a generic box here gets ignored</td>
</tr>
<tr>
<td>A dedicated page for one artifact, linked from articles</td>
<td>Actively wants that thing</td>
<td>the thing itself, named</td>
<td>one page per artifact, or the promise blurs</td>
</tr>
<tr>
<td>Mid-article, after the first working example</td>
<td>Just got something to run</td>
<td>"the rest of this series"</td>
<td>only on posts long enough to have a first example</td>
</tr>
<tr>
<td>Docs / getting-started sequence</td>
<td>Halfway through setting something up</td>
<td>"email me when part 3 is out"</td>
<td>do not gate the page they need to finish step 2</td>
</tr>
<tr>
<td>Footer</td>
<td>Anywhere, leaving</td>
<td>publication list</td>
<td>it is a net, not a channel; never make it the only form</td>
</tr>
<tr>
<td>Header nav / top of every page</td>
<td>Has not read anything yet</td>
<td>nothing they can evaluate</td>
<td>low yield per pixel, and it costs you the top of the page</td>
</tr>
<tr>
<td>Popup on arrival or on exit</td>
<td>Interrupted</td>
<td>aggressive framing</td>
<td>on mobile it usually loses more than it gains</td>
</tr>
</tbody></table>
<p>Rules that hold regardless of layout:</p>
<ol>
<li><strong>One capture point per page.</strong> Two forms on the same page split attention and your measurement.</li>
<li><strong>Never gate your main search entry point.</strong> If a page brings in organic traffic, the content stays open and the form offers the next step.</li>
<li><strong>Never put the form above the first code block or the first example.</strong> The reader has not received value yet, so the ask is unearned.</li>
<li><strong>Name the artifact in the button, not the word "newsletter".</strong> "Send me the checklist" beats "Subscribe".</li>
</ol>
<h2>Step 3 — Build the form (and the fields you will regret skipping)</h2>
<p>Checklist for the form itself:</p>
<ul>
<li>One visible field. Every extra field costs submissions; if you need to segment, do it from behaviour or a hidden field, not from a question.</li>
<li>Label above the input, always visible. A placeholder that disappears is not a label.</li>
<li><code>type="email"</code>, <code>autocomplete="email"</code>, <code>inputmode="email"</code> so mobile keyboards behave.</li>
<li>Button text states the outcome.</li>
<li>One line of fine print: how often, and how to leave.</li>
<li>A success state that replaces the form and tells the reader what happens next ("check your inbox, the confirm link is there").</li>
<li>Honeypot field plus a timestamp check instead of a CAPTCHA. CAPTCHAs cost more real signups than they save.</li>
<li>A hidden <code>source</code> field carrying the page slug. This is the only way you will ever know which placement worked.</li>
</ul>
<p>A form you can paste into a template and wire to your own handler:</p>
<pre><code class="language-html">&lt;form action='/subscribe' method='post' class='capture'&gt;
  &lt;label for='email'&gt;Where should I send the checklist?&lt;/label&gt;
  &lt;input id='email' name='email' type='email'
         autocomplete='email' inputmode='email' required&gt;

  &lt;input type='hidden' name='source' value='post-template-cache'&gt;

  &lt;!-- honeypot: hidden from people, tempting to bots --&gt;
  &lt;div aria-hidden='true' style='position:absolute;left:-9999px'&gt;
    &lt;label for='website'&gt;Website&lt;/label&gt;
    &lt;input id='website' name='website' type='text' tabindex='-1' autocomplete='off'&gt;
  &lt;/div&gt;

  &lt;button type='submit'&gt;Send me the checklist&lt;/button&gt;
  &lt;p class='fineprint'&gt;One email when there is something worth your time. One-click unsubscribe in every message.&lt;/p&gt;
&lt;/form&gt;
</code></pre>
<p>Server side, reject the submission if the honeypot is filled or if the form was posted in under about two seconds. Store the address, the timestamp, the <code>source</code> value, and a consent flag. Do not store more than you need — an IP address is personal data in several jurisdictions.</p>
<h2>Step 4 — The confirm and delivery step</h2>
<p>Use double opt-in. Not for legal theatre: it filters typos, blocks list-poisoning, and gives you a record of consent with a date and a source page attached.</p>
<p>Sequence of events on submit:</p>
<ol>
<li>Store the address as <strong>pending</strong> with <code>source</code> and timestamp.</li>
<li>Send the confirmation immediately, in plain text, with one link. No images, no header graphics, no second call to action.</li>
<li>On confirm, send the artifact in the same session — do not make the reader wait for a scheduled campaign.</li>
<li>Set the sending domain up before you send volume: SPF, DKIM and a DMARC record on the subdomain you send from. Skipping this is the most common reason a working form produces signups nobody ever receives.</li>
<li>Keep marketing mail on its own subdomain or stream, separate from password resets and receipts. One complaint spike should not take down transactional mail.</li>
</ol>
<p>Every message, including the first, carries a working one-click unsubscribe. Make sure the reply-to is a real inbox you read — replies are the highest-value signal you will get.</p>
<h2>Step 5 — The follow-up, which is the actual product</h2>
<p>Five messages, then a rhythm. Keep each one to a single idea and readable on a phone without scrolling twice.</p>
<table>
<thead>
<tr>
<th>When</th>
<th>Job of the email</th>
<th>Length</th>
</tr>
</thead>
<tbody><tr>
<td>Immediately</td>
<td>deliver the thing, confirm it works</td>
<td>3–5 lines</td>
</tr>
<tr>
<td>Day 1</td>
<td>one tip on how to use the thing, no ask</td>
<td>150 words</td>
</tr>
<tr>
<td>Day 3</td>
<td>the next question the reader will hit, answered</td>
<td>300 words, link to one post</td>
</tr>
<tr>
<td>Day 7</td>
<td>a worked example: what goes wrong and the fix</td>
<td>300 words</td>
</tr>
<tr>
<td>Day 14</td>
<td>ask them one question and invite a reply</td>
<td>100 words, no link</td>
</tr>
<tr>
<td>After</td>
<td>one email per new post, or monthly</td>
<td>whatever the post is</td>
</tr>
</tbody></table>
<p>Rules that keep it from becoming noise:</p>
<ul>
<li>One idea per email. Two links maximum, and only if the second is the source.</li>
<li>Plain text or near-plain. A wall of images and buttons reads as bulk mail to both filters and people.</li>
<li>Write the subject as the specific content, not as curiosity bait.</li>
<li>If someone has not clicked anything in about two months, move them to a low-frequency segment rather than deleting them. Deletion is irreversible; a quiet segment is not.</li>
<li>Turn off the sequence the moment someone replies. A human reply outranks any automation.</li>
</ul>
<h2>Step 6 — Measure per placement, not overall</h2>
<p>Group submissions by the hidden <code>source</code> field. The numbers worth watching, in order:</p>
<ol>
<li><strong>Confirm rate</strong> — confirmations divided by submissions. If it is low, the promise and the delivered artifact do not match, or the confirm mail is landing in spam.</li>
<li><strong>Click rate on the Day 1 email.</strong> This is the honest test of whether the list is alive.</li>
<li><strong>Reply rate on the Day 14 email.</strong> Low volume, high signal.</li>
<li><strong>Unsubscribes per send</strong>, as a trend, not a single number.</li>
</ol>
<p>Open rate is unreliable now that clients pre-fetch images. Do not make decisions on it.</p>
<p>Do not judge a placement on a handful of submissions — you cannot tell two placements apart until each has a few hundred, and most personal sites never get there. In that case, pick the placement by intent (end of article, artifact page) and stop optimising.</p>
<h2>A one-week checklist</h2>
<ul>
<li><input disabled type="checkbox" /> One sentence written: what they get, how often.</li>
<li><input disabled type="checkbox" /> One artifact exists and is ready to send before the form goes live.</li>
<li><input disabled type="checkbox" /> Form placed at the end of the two best articles, not in the header.</li>
<li><input disabled type="checkbox" /> Single field, visible label, outcome-shaped button text, fine print with frequency and exit.</li>
<li><input disabled type="checkbox" /> Honeypot plus timing check; no CAPTCHA.</li>
<li><input disabled type="checkbox" /> Hidden <code>source</code> field on every instance, different value per placement.</li>
<li><input disabled type="checkbox" /> Double opt-in, consent stored with date and source.</li>
<li><input disabled type="checkbox" /> SPF, DKIM, DMARC on the sending subdomain, tested by sending to yourself at two providers.</li>
<li><input disabled type="checkbox" /> One-click unsubscribe in every message, including the first.</li>
<li><input disabled type="checkbox" /> Emails 1 through 5 written, not just the first one.</li>
<li><input disabled type="checkbox" /> Reply-to is an inbox you actually read.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Custom post type or WooCommerce products: picking the data model for a product catalog]]></title><description><![CDATA[WooCommerce products are a custom post type too — product — with a data layer bolted on: a lookup table that mirrors price and stock, attribute taxon…]]></description><link>https://aumcreate.hashnode.dev/custom-post-type-or-woocommerce-products-picking-the-data-model-for-a-product-catalog</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/custom-post-type-or-woocommerce-products-picking-the-data-model-for-a-product-catalog</guid><category><![CDATA[SEO]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Tue, 27 Oct 2026 03:29:32 GMT</pubDate><content:encoded><![CDATA[<p>WooCommerce products are a custom post type too — <code>product</code> — with a data layer bolted on: a lookup table that mirrors price and stock, attribute taxonomies, a product query class, and its own REST controller. So the real question is not "CPT or CPT". It is: do you want that data layer, or do you want to own the schema yourself?</p>
<p>Short version, before the code:</p>
<ul>
<li><strong>No prices, no cart, no stock — spec sheets and a quote form</strong> → your own CPT is lighter, the admin screen is yours, and you are not maintaining a shop you do not use.</li>
<li><strong>You will take payment, need variants, stock counts, tax, or a CSV round-trip</strong> → start from products, even if you hide the cart on day one. Moving a custom catalog into products later is a data migration, not a settings change.</li>
</ul>
<h2>1. What actually differs</h2>
<table>
<thead>
<tr>
<th>Need</th>
<th>Own CPT</th>
<th>WooCommerce product</th>
</tr>
</thead>
<tbody><tr>
<td>Price stored and sorted</td>
<td>a meta field, sorted through postmeta</td>
<td><code>_price</code> mirrored into <code>wc_product_meta_lookup</code>, sorted in SQL without touching postmeta</td>
</tr>
<tr>
<td>Variants</td>
<td>you model parent/child yourself</td>
<td>variable products + attribute taxonomies (<code>pa_*</code>)</td>
</tr>
<tr>
<td>Stock</td>
<td>your own meta and your own logic</td>
<td>stock status, quantity, backorders</td>
</tr>
<tr>
<td>Cart, checkout, tax, shipping, coupons</td>
<td>build it, or do without</td>
<td>present</td>
</tr>
<tr>
<td>CSV import/export</td>
<td>write it</td>
<td>product importer/exporter in the admin</td>
</tr>
<tr>
<td>REST</td>
<td><code>/wp-json/wp/v2/&lt;your_type&gt;</code></td>
<td><code>/wp-json/wc/v3/products</code> with key/secret auth</td>
</tr>
<tr>
<td>Admin UI</td>
<td>whatever meta boxes you register</td>
<td>product editor, bulk edit, quick edit</td>
</tr>
<tr>
<td>Frontend templates</td>
<td><code>archive-&lt;type&gt;.php</code>, <code>single-&lt;type&gt;.php</code></td>
<td><code>woocommerce/</code> overrides inside the theme</td>
</tr>
<tr>
<td>Hooks</td>
<td><code>save_post_&lt;type&gt;</code>, <code>manage_&lt;type&gt;_posts_columns</code></td>
<td><code>woocommerce_*</code> actions, getter/setter filters</td>
</tr>
<tr>
<td>DB footprint</td>
<td>posts + postmeta</td>
<td>posts + postmeta + lookup table + order tables</td>
</tr>
</tbody></table>
<p>The row that decides most projects is the first one. Sorting 2,000 products by price through <code>meta_key</code> means a <code>JOIN</code> on <code>wp_postmeta</code> on every archive page. WooCommerce keeps <code>wc_product_meta_lookup</code> (product_id, sku, price, regular_price, sale_price, stock_status, stock_quantity, rating averages) so that sorting and filtering by price or stock never touches postmeta. If price is part of your catalog's browsing experience, that table is the feature.</p>
<h2>2. The CPT route</h2>
<p>This is one small plugin file. Drop it in <code>wp-content/plugins/catalog-cpt/catalog-cpt.php</code>, or in <code>mu-plugins</code> if you never want it deactivated.</p>
<pre><code class="language-php">&lt;?php
/**
 * Plugin Name: Catalog CPT
 */

add_action( 'init', function () {
	register_post_type( 'catalog_item', [
		'labels'       =&gt; [
			'name'          =&gt; 'Catalog items',
			'singular_name' =&gt; 'Catalog item',
		],
		'public'       =&gt; true,
		'has_archive'  =&gt; true,
		'menu_icon'    =&gt; 'dashicons-products',
		'supports'     =&gt; [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ],
		'show_in_rest' =&gt; true,
		'rewrite'      =&gt; [ 'slug' =&gt; 'catalog', 'with_front' =&gt; false ],
	] );

	register_taxonomy( 'catalog_family', 'catalog_item', [
		'label'        =&gt; 'Families',
		'public'       =&gt; true,
		'hierarchical' =&gt; true,
		'show_in_rest' =&gt; true,
		'rewrite'      =&gt; [ 'slug' =&gt; 'catalog-family', 'with_front' =&gt; false ],
	] );
} );
</code></pre>
<p>Rewrite rules need a flush once. Visit Settings → Permalinks and save, or if this is a normal plugin add:</p>
<pre><code class="language-php">register_activation_hook( __FILE__, function () {
	flush_rewrite_rules();
} );
</code></pre>
<p>Now the fields. Register them so REST and the block editor both see them, then add a plain meta box for the classic screen:</p>
<pre><code class="language-php">add_action( 'init', function () {
	$fields = [
		'_catalog_sku'          =&gt; 'string',
		'_catalog_power'        =&gt; 'number',
		'_catalog_datasheet_id' =&gt; 'integer',
	];

	foreach ( $fields as $key =&gt; $type ) {
		register_post_meta( 'catalog_item', $key, [
			'type'              =&gt; $type,
			'single'            =&gt; true,
			'show_in_rest'      =&gt; true,
			'sanitize_callback' =&gt; ( 'number' === $type || 'integer' === $type ) ? 'absint' : 'sanitize_text_field',
			'auth_callback'     =&gt; function () {
				return current_user_can( 'edit_posts' );
			},
		] );
	}
} );

add_action( 'add_meta_boxes', function () {
	add_meta_box( 'catalog_fields', 'Catalog fields', 'my_catalog_box', 'catalog_item', 'normal', 'high' );
} );

function my_catalog_box( $post ) {
	wp_nonce_field( 'my_catalog_save', 'my_catalog_nonce' );

	$sku   = get_post_meta( $post-&gt;ID, '_catalog_sku', true );
	$power = get_post_meta( $post-&gt;ID, '_catalog_power', true );

	printf(
		'&lt;p&gt;&lt;label&gt;SKU&lt;br /&gt;&lt;input type="text" class="widefat" name="catalog_sku" value="%s" /&gt;&lt;/label&gt;&lt;/p&gt;',
		esc_attr( $sku )
	);
	printf(
		'&lt;p&gt;&lt;label&gt;Power (W)&lt;br /&gt;&lt;input type="number" class="widefat" name="catalog_power" value="%s" /&gt;&lt;/label&gt;&lt;/p&gt;',
		esc_attr( $power )
	);
}

add_action( 'save_post_catalog_item', function ( $post_id ) {
	if ( defined( 'DOING_AUTOSAVE' ) &amp;&amp; DOING_AUTOSAVE ) {
		return;
	}
	if ( ! isset( $_POST['my_catalog_nonce'] ) || ! wp_verify_nonce( $_POST['my_catalog_nonce'], 'my_catalog_save' ) ) {
		return;
	}
	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}

	if ( isset( $_POST['catalog_sku'] ) ) {
		update_post_meta( $post_id, '_catalog_sku', sanitize_text_field( wp_unslash( $_POST['catalog_sku'] ) ) );
	}
	if ( isset( $_POST['catalog_power'] ) ) {
		update_post_meta( $post_id, '_catalog_power', absint( $_POST['catalog_power'] ) );
	}
} );
</code></pre>
<p>Two things bite people here. First, <code>register_post_meta</code> only exposes the value in the REST response under <code>meta</code> — it does not render an input. Without a meta box or a block-editor field, editors can't set it. Second, the sanitize callback runs on every <code>update_post_meta</code> call too, so do not point it at a function that expects raw <code>$_POST</code> superglobals.</p>
<h2>3. The Woo route</h2>
<p>The equivalent registration is not a <code>register_post_type</code> call — WooCommerce already registered <code>product</code>. What you register is your <strong>spec fields</strong>. Keep browsable values in attributes (taxonomies) and the rest in meta:</p>
<pre><code class="language-php">add_action( 'init', function () {
	register_post_meta( 'product', '_datasheet_id', [
		'type'              =&gt; 'integer',
		'single'            =&gt; true,
		'show_in_rest'      =&gt; true,
		'sanitize_callback' =&gt; 'absint',
		'auth_callback'     =&gt; function () {
			return current_user_can( 'edit_products' );
		},
	] );
} );
</code></pre>
<p>Anything a visitor will filter by should be a global product attribute (Products → Attributes), because that gives you a <code>pa_*</code> taxonomy you can query. Anything only shown on the product page — datasheet ID, dielectric, tolerance band — is fine as post meta.</p>
<h2>4. Queries</h2>
<p><strong>CPT, sorted by a spec field, filtered by taxonomy:</strong></p>
<pre><code class="language-php">$query = new WP_Query( [
	'post_type'      =&gt; 'catalog_item',
	'posts_per_page' =&gt; 24,
	'meta_key'       =&gt; '_catalog_power',
	'orderby'        =&gt; 'meta_value_num',
	'order'          =&gt; 'ASC',
	'tax_query'      =&gt; [
		[
			'taxonomy' =&gt; 'catalog_family',
			'field'    =&gt; 'slug',
			'terms'    =&gt; [ 'mosfets' ],
		],
	],
] );

foreach ( $query-&gt;posts as $post ) {
	printf(
		'&lt;li&gt;%s — %s W&lt;/li&gt;',
		esc_html( get_the_title( $post ) ),
		esc_html( get_post_meta( $post-&gt;ID, '_catalog_power', true ) )
	);
}
</code></pre>
<p>The gotcha: using <code>meta_key</code> for ordering switches that query to an <code>INNER JOIN</code> on postmeta, so <strong>posts without <code>_catalog_power</code> silently disappear</strong>. If some items have no value, either backfill them or sort in PHP.</p>
<p><strong>CPT, numeric range filter:</strong></p>
<pre><code class="language-php">'meta_query' =&gt; [
	[
		'key'     =&gt; '_catalog_power',
		'value'   =&gt; [ 50, 150 ],
		'compare' =&gt; 'BETWEEN',
		'type'    =&gt; 'NUMERIC',
	],
],
</code></pre>
<p><strong>Products, sorted by price:</strong></p>
<pre><code class="language-php">$products = wc_get_products( [
	'status'  =&gt; 'publish',
	'limit'   =&gt; 24,
	'orderby' =&gt; 'price',
	'order'   =&gt; 'ASC',
	'return'  =&gt; 'objects',
] );

foreach ( $products as $product ) {
	printf(
		'&lt;li&gt;%s — %s&lt;/li&gt;',
		esc_html( $product-&gt;get_name() ),
		wp_kses_post( wc_price( $product-&gt;get_price() ) )
	);
}
</code></pre>
<p><code>wc_get_products()</code> resolves <code>orderby =&gt; 'price'</code> through the lookup table. If you instead run <code>WP_Query</code> on <code>post_type =&gt; product</code> with <code>meta_key =&gt; '_price'</code>, you get the postmeta join again and lose the point of the shop tables.</p>
<p><strong>Products, filtered by category and a global attribute</strong> — use <code>WP_Query</code> for the taxonomy work, then wrap the IDs:</p>
<pre><code class="language-php">$q = new WP_Query( [
	'post_type'      =&gt; 'product',
	'posts_per_page' =&gt; 24,
	'fields'         =&gt; 'ids',
	'tax_query'      =&gt; [
		'relation' =&gt; 'AND',
		[ 'taxonomy' =&gt; 'product_cat', 'field' =&gt; 'slug', 'terms' =&gt; [ 'mosfets' ] ],
		[ 'taxonomy' =&gt; 'pa_package',  'field' =&gt; 'slug', 'terms' =&gt; [ 'to-247' ] ],
	],
] );

foreach ( $q-&gt;posts as $id ) {
	$product = wc_get_product( $id );
	if ( $product ) {
		echo esc_html( $product-&gt;get_name() );
	}
}
</code></pre>
<h2>5. What you gain and give up in each direction</h2>
<p><strong>CPT gives you:</strong> a clean admin, no cart code loading on catalog pages, total control over the URL structure and the single-item template, and no obligation to keep shop tables consistent.</p>
<p><strong>CPT costs you:</strong> variants, stock, tax, coupons, order emails, the CSV round-trip, and the whole <code>woocommerce_*</code> hook ecosystem. Every one of those becomes your code.</p>
<p><strong>Products give you:</strong> the data layer, the REST endpoints, the importer, and template overrides that live in <code>yourtheme/woocommerce/</code> where a theme developer expects them.</p>
<p><strong>Products cost you:</strong> more tables, more queries on unrelated pages, a heavier admin, and the constant temptation to install something that changes price output via <code>woocommerce_get_price_html</code> and breaks your spec display.</p>
<h2>6. Decision rules</h2>
<p>Ask these three questions before you write the first line of registration code:</p>
<ol>
<li>Will money ever change hands through the site? If yes — products.</li>
<li>Will a visitor ever need to see "in stock" or pick between variants of the same part? If yes — products.</li>
<li>Does your catalog exist to generate a quote request, with prices "on request" and no checkout in sight? If yes — your own CPT is the cheaper build, and staying on it is fine.</li>
</ol>
<p>If the answer to 1 or 2 is "maybe next year", start on products and hide the cart with a filter until you launch the shop. Converting <code>catalog_item</code> posts into products later means re-creating every item through <code>WC_Product_Simple</code>, remapping taxonomies to <code>product_cat</code> and <code>pa_*</code>, and re-pointing every internal link — a day of work for a small catalog, a project for a large one.</p>
<p>If you are handing the catalog layer to someone else because the spec sheets and the quoting flow are the actual work, that sits under <a href="https://aumcreate.com/services?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-catalog-cpt-vs-product-fields">custom development</a> rather than a template install.</p>
<p>One last thing on REST: with <code>show_in_rest =&gt; true</code> on the CPT and on the meta, <code>/wp-json/wp/v2/catalog_item</code> gives you both the post and a <code>meta</code> object in one response, which is usually all a front-end needs. The products endpoint requires WooCommerce's key/secret pair, so plan the auth before you promise a client a headless front end.</p>
]]></content:encoded></item><item><title><![CDATA[How to tell GPTBot, ClaudeBot, and ordinary monitoring apart in access logs]]></title><description><![CDATA[Short answer: the GPTBot and ClaudeBot user-agent tokens identify what the request claims to be, but a user agent is self-declared.]]></description><link>https://aumcreate.hashnode.dev/how-to-tell-gptbot-claudebot-and-ordinary-monitoring-apart-in-access-logs</link><guid isPermaLink="true">https://aumcreate.hashnode.dev/how-to-tell-gptbot-claudebot-and-ordinary-monitoring-apart-in-access-logs</guid><category><![CDATA[apache]]></category><dc:creator><![CDATA[AumCreate]]></dc:creator><pubDate>Mon, 26 Oct 2026 05:35:56 GMT</pubDate><content:encoded><![CDATA[<p>Short answer: the <code>GPTBot</code> and <code>ClaudeBot</code> user-agent tokens identify what the request claims to be, but a user agent is self-declared. Verify the source IP against the crawler operator's published range list. Crawl frequency and path breadth help you separate a broad crawler from a narrow uptime monitor, but they do not reliably separate GPTBot from ClaudeBot. For that, use the token plus the verified IP range.</p>
<h2>What each signal can and cannot tell you</h2>
<table>
<thead>
<tr>
<th>Signal</th>
<th>What it is good for</th>
<th>Where it fails</th>
</tr>
</thead>
<tbody><tr>
<td>User-Agent token</td>
<td>Separating <code>GPTBot</code> from <code>ClaudeBot</code> claims</td>
<td>Easy to forge; a monitor can also use a custom UA</td>
</tr>
<tr>
<td>Source IP range</td>
<td>Confirming that the request came from the operator's network</td>
<td>Ranges change; some operators may not publish a list</td>
</tr>
<tr>
<td>Path breadth</td>
<td>Separating a broad crawler from a one-URL monitor</td>
<td>A crawler can be limited to a section; a monitor can be configured to crawl</td>
</tr>
<tr>
<td>Request frequency</td>
<td>Spotting regular polling or bursts</td>
<td>GPTBot and ClaudeBot rates are not fixed identities; both can be distributed</td>
</tr>
<tr>
<td>Method and headers</td>
<td>Adding evidence: <code>HEAD</code>, no cookies, no referrer</td>
<td>Not unique to any one crawler</td>
</tr>
</tbody></table>
<p>The practical rule: treat the UA as a label, the IP range as verification, and frequency and paths as behavior clues.</p>
<h2>Step 1: Make sure the log has the fields you need</h2>
<p>Use an access log format that includes the request line, status, referrer, and user agent. The common combined format has all of these. If the site sits behind a reverse proxy or CDN, configure the origin to log the original client IP from a trusted header; otherwise every request may look like it came from the proxy.</p>
<p>You do not need to log request bodies or cookies for this. Do not start logging secrets just to identify crawlers.</p>
<h2>Step 2: Build a small IP range file</h2>
<p>Create <code>crawler-ips.txt</code>. Each line is a crawler name and a CIDR range. Comments start with <code>#</code>.</p>
<pre><code class="language-text"># Placeholders. Replace with current ranges from each operator's crawler documentation.
GPTBot 203.0.113.0/24
ClaudeBot 198.51.100.0/24
</code></pre>
<p>The ranges above are documentation examples, not live crawler ranges. Check the crawler operator's official crawler documentation for the current list. If you cannot find a published range for a token, leave it out of the file and keep that token in the unverified bucket.</p>
<h2>Step 3: Run a log audit script</h2>
<p>Save this as <code>ai_crawler_audit.py</code>. It uses only the Python standard library.</p>
<pre><code class="language-python">#!/usr/bin/env python3
import shlex
import sys
from collections import Counter, defaultdict
from datetime import datetime
from ipaddress import ip_address, ip_network
from statistics import median

AI_TOKENS = ('GPTBot', 'ClaudeBot')
MONITOR_HINTS = (
    'uptime', 'monitor', 'health', 'ping', 'probe',
    'curl', 'wget', 'python-requests', 'go-http-client', 'libwww',
)

def load_networks(path):
    networks = defaultdict(list)
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            name, cidr = line.split()
            networks[name].append(ip_network(cidr))
    return networks

def ai_token(ua):
    low = ua.lower()
    for token in AI_TOKENS:
        if token.lower() in low:
            return token
    return None

def monitor_like(rec):
    ua = rec['ua'].lower()
    if any(h in ua for h in MONITOR_HINTS):
        return True
    path = rec['path'].split('?', 1)[0]
    if rec['method'] == 'HEAD' and path in ('/', '/health', '/healthz', '/status', '/ping'):
        return True
    return False

def parse_line(line):
    try:
        parts = shlex.split(line, comments=False, posix=True)
    except ValueError:
        return None
    if len(parts) &lt; 10:
        return None
    try:
        ip_obj = ip_address(parts[0])
        ts = parts[3].lstrip('[') + ' ' + parts[4].rstrip(']')
        ts_dt = datetime.strptime(ts, '%d/%b/%Y:%H:%M:%S %z')
        method, path, _ = parts[5].split(' ', 2)
    except ValueError:
        return None
    return {
        'ip': parts[0],
        'ip_obj': ip_obj,
        'ts': ts_dt,
        'method': method,
        'path': path,
        'status': parts[6],
        'ua': parts[9],
    }

def group_for(rec, networks):
    token = ai_token(rec['ua'])
    if token:
        verified = any(rec['ip_obj'] in net for net in networks.get(token, []))
        return token if verified else f'{token} (unverified UA)'
    if monitor_like(rec):
        return 'monitor candidate'
    return 'other'

def summarize(records):
    by_ip = defaultdict(list)
    for r in records:
        by_ip[r['ip']].append(r['ts'])
    gaps = []
    for times in by_ip.values():
        times.sort()
        gaps.extend((b - a).total_seconds() for a, b in zip(times, times[1:]))
    paths = Counter(r['path'].split('?', 1)[0] for r in records)
    minutes = Counter(r['ts'].strftime('%Y-%m-%d %H:%M') for r in records)
    return {
        'requests': len(records),
        'unique_ips': len(by_ip),
        'unique_paths': len(paths),
        'methods': dict(Counter(r['method'] for r in records)),
        'status_classes': dict(Counter(r['status'][0] + 'xx' for r in records)),
        'top_paths': paths.most_common(5),
        'peak_requests_per_minute': max(minutes.values(), default=0),
        'median_gap_same_ip_s': round(median(gaps), 1) if gaps else None,
        'max_requests_one_ip': max((len(v) for v in by_ip.values()), default=0),
    }

def main():
    if len(sys.argv) != 3:
        print('usage: python3 ai_crawler_audit.py access.log crawler-ips.txt')
        return 1
    networks = load_networks(sys.argv[2])
    groups = defaultdict(list)
    with open(sys.argv[1], errors='replace') as f:
        for line in f:
            rec = parse_line(line.rstrip())
            if rec:
                groups[group_for(rec, networks)].append(rec)
    for name, records in sorted(groups.items(), key=lambda item: -len(item[1])):
        print(f'## {name}')
        for key, value in summarize(records).items():
            print(f'{key}: {value}')
        print()
    return 0

if __name__ == '__main__':
    raise SystemExit(main())
</code></pre>
<p>Run it against a combined access log:</p>
<pre><code class="language-bash">python3 ai_crawler_audit.py /var/log/nginx/access.log crawler-ips.txt
</code></pre>
<p>If your log format is not combined, adjust the field positions in <code>parse_line</code>. Keep the same output fields so the rest of the script still works.</p>
<h2>Step 4: Read the groups, not just the totals</h2>
<p>The script puts each request into one of these groups:</p>
<ul>
<li><code>GPTBot</code> or <code>ClaudeBot</code>: the UA contains the token and the source IP matches the range file.</li>
<li><code>GPTBot (unverified UA)</code> or <code>ClaudeBot (unverified UA)</code>: the UA claims the token, but the IP is not in the range file. This is either a stale range file, a proxy, or a spoofed UA. Investigate before counting it as the real crawler.</li>
<li><code>monitor candidate</code>: the UA or method looks like uptime or health checking. This is a heuristic, not proof.</li>
<li><code>other</code>: everything else.</li>
</ul>
<p>For each group, look at:</p>
<ul>
<li><code>unique_ips</code> and <code>max_requests_one_ip</code></li>
<li><code>unique_paths</code> and <code>top_paths</code></li>
<li><code>peak_requests_per_minute</code></li>
<li><code>median_gap_same_ip_s</code></li>
<li><code>methods</code> and <code>status_classes</code></li>
</ul>
<p>A broad crawler usually has many unique paths and may use many IPs. A simple monitor usually has a small path set, a small IP set, and a regular interval. Use those as clues, then confirm with the UA and IP range.</p>
<h2>Step 5 (optional route): skip log parsing and read the hits in WordPress</h2>
<p>Log analysis is the route that gives you the most control and the most work. If the site is on WordPress and you do not want to keep a range file current, the free AumCrawl plugin is one option that sits alongside this workflow rather than replacing it: it records which AI crawlers are requesting the site and lets you allow or block each crawler separately. That is useful for the "allow or deny per crawler" decision, which is a policy question rather than a parsing question. It does not give you the same freedom as raw logs when you need to audit a suspicious UA or a proxy setup, so keep the log script for anything you have to prove.</p>
<p>Plugin page: <a href="https://aumcreate.com/plugins/aumcrawl?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-ai-crawler-log-ua-frequency">https://aumcreate.com/plugins/aumcrawl?utm_source=hashnode&amp;utm_medium=content&amp;utm_campaign=hashnode-ai-crawler-log-ua-frequency</a></p>
<p>Either way, decide the policy after you know the facts: which tokens arrive, which of those verify against the operator ranges, and which paths they touch.</p>
<h2>How to separate the three cases</h2>
<h3>GPTBot vs ClaudeBot</h3>
<p>Start with the token: <code>GPTBot</code> and <code>ClaudeBot</code> are different strings. Then verify the source IP against the matching range file. Do not use frequency to decide which one it is. A slow crawl from one IP and a fast crawl from many IPs are both possible; the operator and the site configuration change the rate.</p>
<p>If the token is <code>GPTBot</code> but the IP is not in the GPTBot ranges, do not relabel it as ClaudeBot or as a monitor. Keep it unverified. It may be a proxy, a stale range list, or a forged UA.</p>
<h3>Crawler vs ordinary monitoring</h3>
<p>Look for the combination:</p>
<ul>
<li>A monitor usually repeats a small set of paths such as <code>/</code>, <code>/health</code>, <code>/status</code>, <code>/ping</code>, or <code>/robots.txt</code>.</li>
<li>A monitor often uses <code>HEAD</code> or a simple <code>GET</code>, and may send no cookies or referrer.</li>
<li>A monitor often comes from one or a few IPs at a fixed interval.</li>
<li>A broad crawler usually requests many different URLs and may spread across many IPs.</li>
<li>A crawler may also request only HTML and skip assets, so asset requests are not a reliable test.</li>
</ul>
<p>No single row is enough. For example, a <code>GET /</code> from a generic HTTP client can be a monitor, a search engine, or a person with a script. The UA plus the IP range plus the path pattern is the useful combination.</p>
<h2>Frequency checks that are useful</h2>
<p><code>peak_requests_per_minute</code> tells you whether a group arrives in bursts. <code>median_gap_same_ip_s</code> tells you the typical time between requests from the same IP. Keep them separate:</p>
<ul>
<li>Low unique paths plus high peak plus few IPs points to polling or health checks.</li>
<li>High unique paths plus many IPs points to a broad crawl.</li>
<li>High unique paths plus one IP can be a small crawler, a mirror, or a scraper.</li>
<li>Low unique paths plus many IPs can be a distributed monitor or a proxy pool.</li>
</ul>
<p>Do not turn these into a fixed rule. Logs from different sites will produce different shapes. The point is to form a hypothesis and verify it with the UA and IP range.</p>
<h2>Common mistakes</h2>
<ul>
<li>Trusting the UA alone. It is a claim, not proof.</li>
<li>Treating a high request rate as proof of GPTBot or ClaudeBot. Frequency is behavior, not identity.</li>
<li>Forgetting the proxy. If the origin sees only the proxy IP, IP verification will fail.</li>
<li>Using a stale range file. Refresh it on a schedule.</li>
<li>Calling every <code>GET /</code> a monitor. Many real users and crawlers request the home page.</li>
<li>Counting an unverified UA as a confirmed crawler in reports.</li>
</ul>
<h2>Checklist</h2>
<ul>
<li><input disabled type="checkbox" /> The access log includes the user agent and the request path.</li>
<li><input disabled type="checkbox" /> The origin logs the real client IP, not only a proxy IP.</li>
<li><input disabled type="checkbox" /> <code>crawler-ips.txt</code> uses current operator ranges, not the placeholders.</li>
<li><input disabled type="checkbox" /> The audit script runs without errors on a sample log.</li>
<li><input disabled type="checkbox" /> Unverified UA groups are reviewed before they are counted.</li>
<li><input disabled type="checkbox" /> Frequency and path breadth are used as clues, not as identity.</li>
<li><input disabled type="checkbox" /> The range file is refreshed on a schedule.</li>
</ul>
]]></content:encoded></item></channel></rss>