---
name: wordpress-seo-content-ops
description: "WP client SEO: Yoast meta, internal links, GA checks."
---

# WordPress SEO & Content Ops (client sites via REST)

Class-level workflow for managed client WP sites via WP REST + GSC/GA4; kredensial & property ID di workspace + memory.

Key refs (`references/`): organic-collapse-triage, ga4-keyword-sources, cta-button-audit-and-standardize, cannibal-cluster-consolidation, mass-content-edit-integrity, conversion-tracking-diagnostics, plugin-state-and-block-edits, gsc-ga4-target-and-visibility-recovery, site-wide-phone-and-nap-audit, legacy-number-and-brand-replacement, gbp-post-caption-pack, live-page-capture-for-approval, featured-image-audit-and-fill, page-front-guard-and-internal-links, local-pack-vs-organic-keyword-priority, new-service-article-and-dead-cluster-revival. Skrip: ga4_outbound_click_report, gsc_sitemap_crawl_health, wp_content_integrity_probe, sitemap_needle_crawl, kategorikan_sebutan_merek, cek_caption_pack, capture_teks_occurrences (`/usr/bin/python3`).

## Hard rules from the client owner (Uky "Bos")
- **Pages: no editorial work.** Owner: *"jangan handle page"*. Jangan tulis ulang isi/judul/meta page; baca untuk diagnosis boleh. Kecuali sweep mekanis yang pemilik perintahkan (nomor WA, tautan domain mati, label merek): itu menyentuh page juga — backup + verifikasi. Lihat `references/legacy-number-and-brand-replacement.md`. Kalau ada masalah page: laporkan string persis + panelnya, lalu berhenti. Exception historis (studiosoft 1488) sudah ditutup.
- **Beranda = wilayah pemilik.** Owner: *"untuk home biar aku sendiri"* dan *"pastikan tidak ubah design halaman beranda, karena jika kamu ubah desain berarti hancur semuanya"*. Jangan pernah mengirim ulang `content` beranda. Yang boleh: meta (SEO title, meta description, focus keyphrase). Buktikan desain aman dengan sidik jari isi halaman (sha256 `content.raw`) + tangkapan layar sebelum/sesudah, dan laporkan angkanya ke owner.
- **Kalau jalur meta tertutup, telusuri ladder di `references/wp-api-write-failures.md` DAN `references/yoast-meta-rest.md` (bulk editor Yoast bisa untuk halaman) sebelum menyatakan "tidak bisa"** — lalu serahkan teks siap tempel ke owner, jangan memaksa lewat penggantian `content`.
- **Konten medis/klinik (YMYL): jangan mengarang fakta.** Tanpa harga, jam praktik, jadwal dokter, nama paket, atau klaim hasil. Tulis pengetahuan medis umum yang akurat, arahkan rincian ke klinik. Resep: `references/thin-content-expansion.md` §6.
- **Situs klien tanpa formulir → konversinya klik WhatsApp, bukan `form_submit`.** Cek `<form` dulu, lalu tandai key event `click` lewat Admin API: `references/ga4-key-events-and-contact-conversion.md` + `scripts/ga4_key_events.py`.

## Support files (baca sebelum kerja)
- `references/ga4-property-attribution.md` — memilih properti GA4/GSC yang benar; insiden salah properti 27 Sep 2026 (angka surabaya-skincentre.com dilaporkan sebagai milik theskincodeclinic.com) + resep verifikasi `hostName`.
- `references/wp-api-write-failures.md` — kenapa penulisan meta gagal/diam-diam tidak berubah; fault XML-RPC (`Invalid author ID`), Yoast tidak terdaftar di REST, dan uji pemisah per-post vs seluruh situs.
- `references/robots-txt-client-sites.md` — resep robots.txt situs klien + jebakan 403 dari izin berkas (nginx, bukan Cloudflare).
- `references/ga4-key-events-and-contact-conversion.md` — situs tanpa formulir: klik WhatsApp jadi konversi lewat key event GA4 (Admin API), plus batasan yang wajib dilaporkan jujur.
- `references/slug-change-link-sweep.md` — sapu tautan lama setelah owner mengganti slug halaman; cara memastikan jenis URL yang sudah dihapus (halaman/artikel) tanpa menebak.
- `references/wa-button-standardization.md` — seragamkan tombol WhatsApp massal: survei bentuk/nomor, target `wa.me` bersih, jebakan entitas `&amp;` saat membandingkan, dan tautan tingkat tema yang harus diserahkan ke owner.
- `scripts/wp_internal_link_pass.py` — pass tautan internal massal, idempoten, dry-run default, backup tiap artikel (dipakai di TSLaw: 51 artikel yatim → semua bertaut).
- **Sebut "halaman" untuk `post_type=page` dan "artikel" untuk `post_type=post` — jangan menukar keduanya.** Owner corrected this outright (27 Sep 2026): *"eh itu halaman atau artikel. pakai bahasa yang betul."* It bites hardest when reporting a 404 URL whose type you have not verified — do the check BEFORE the sentence, don't write "bangun ulang 5 halaman" and then offer to "tulis artikel". Order of evidence: (a) `GET /wp/v2/pages` and `/wp/v2/posts` with `status=publish,draft,trash,private` — trash keeps the original type; (b) `GET /wp/v2/menu-items` → the `type`/`object` fields (`post_type` + `page` vs `post`) still name the type of a DELETED item, and menu items mostly survive in the `custom`+anchor shape so absence here proves nothing by itself; (c) which sitemap the URL would belong to, `page-sitemap.xml` vs `post-sitemap.xml`; (d) a Wayback snapshot, if one exists. When all of them come back empty, write **"belum bisa dipastikan"** and ask for access that can answer it — never present a guess about page-vs-post as a fact, because the two have different owners (pages are his, posts are yours) and therefore different next actions.
- **Titles & drafts must be humanist and click-worthy** (curiosity/benefit hooks allowed, clickbait-style OK — but stay truthful; drop puffery words like "Terbaik" for proof words like "Terbukti").
- **Meta description standard (SEO playbook, all client sites):** 120-158 chars, focus keyphrase EXACT MATCH at the start of the sentence, then verify the LIVE page after setting.
- Address the owner as "Bos".
- **Every piece of writing passes the `humanizer` pass — standing owner rule, 17 Sep 2026.** He phrased it as "semua bentuk tulisan yang keluar dari kita harus humanize": client articles, social captions, reports, notes and chat replies alike. Kill significance inflation ("stands as a testament"), forced rule-of-three, em-dash overuse, mechanical boldface, "it's not just X, it's Y", filler, hedged non-answers and sweet-nothing closers; vary sentence length and read it aloud before sending. Frame it to him as an EDITING-QUALITY step, never as detection evasion: he first asked about "Uncheck AI"-style tools, and the answer he accepted is that Google puts no watermark on published text and uses no AI-detector as a ranking signal — what ranks is first-hand proof (own photos, prices, technician names, case studies). Recorded in the workspace `AGENTS.md` under "Voice".
- **Status/connectivity reports to Bos: compact ✅/❌ matrix** (monospace code block, one line per site), not long prose tables.

## Setting Yoast title/meta via REST — two mechanisms
1. **Posts** register `_yoast_wpseo_focuskw/_yoast_wpseo_metadesc/_yoast_wpseo_title` in REST meta → PATCH/POST `/wp/v2/posts/{id}` with `{"meta": {...}}` works.
2. **Pages do NOT register Yoast meta in REST** — meta updates are silently dropped (HTTP 200, nothing saved). For pages, and as an alternate fast path on some sites, use Yoast's own endpoint: `POST /wp-json/yoast/v1/bulk_editor/update_search` body `{"items":[{"id":N,"seo_title":"...","meta_description":"...","focus_keyphrase":"..."}]}` (schema learned from 400-error messages: integer `id`; at least one of seo_title/meta_description/focus_keyphrase).
3. **Per-site quirk:** bulk-editor endpoint 403s on dasindonesia.com (bambangbot role lacks cap) but works on studiosoft.id; plain REST meta works on both. Try REST meta first; if the Yoast field is empty after the call, switch mechanism. See `references/yoast-meta-rest.md`.
4. Always verify live: `curl ?nocache=V` and grep `<title>` + `<meta name=description>` (cache is WP Rocket hourly — bypass param shows the real current state). **On Cloudflare-fronted sites the bypass param does nothing and the page is served from the CF edge (`cf-cache-status: HIT`, `max-age=2592000`) — studiosoft/yasmanatura/rsia ignore `?nocache=`, so those sites need a Cloudflare purge (or a POST) before a live check means anything.**
5. **Audit a whole site's Yoast fields in ONE paged GET** — `GET /wp-json/yoast/v1/bulk_editor/posts?content_type=post&per_page=100&page=N` returns per post `focus_keyphrase, seo_title, meta_description, social_title, social_description` plus a `needs_improvement` map (`seo_title/meta_description/social_title/social_description`), and `include[]=<id>` narrows it to a batch. Two pages surveyed all 42 posts of a site in seconds and surfaced 17 empty metas (leitsurabaya.com, 17 Sep 2026) — use this as the triage step BEFORE any meta pass instead of pulling each post's meta. Script: `scripts/yoast_meta_audit.py`.
6. **Score buckets + front page in the same sweep** — `GET /wp-json/yoast/v1/seo_scores?contentType=post` (and `readability_scores`) return `{good, ok, bad, notAnalyzed}`; `notAnalyzed` is exactly the no-focus-keyphrase population, so it corroborates the audit without per-post reads (leitsurabaya.com 17 Sep 2026: SEO good 6 / ok 20 / bad 0 / **notAnalyzed 16**, readability good 25 / ok 2 / notAnalyzed 15). Then check the FRONT PAGE as its own row — `GET /wp-json/` → `page_on_front`, then that page's `yoast_head_json`. On leitsurabaya.com the homepage title was the literal `Home | LEIT SURABAYA`, meta description **absent from the live HTML** (grep `name="description"` → 0) and `og:image` a POSTRAIT 1039×1300 (crops badly in WhatsApp/FB previews). Front-page fixes are owner-side (pages off-limits) — hand over the exact string guesses as arahan.

## Freshness & AI-Overview reframe passes
Year refresh (title+content year tokens), freshness audit (stale posts, empty metas, junk duplicates), and reframing an existing informational post into AI-Overview format (direct-answer intro + FAQ + FAQPage schema + humanist SERP title) are recurring class tasks with their own recipe: `references/freshness-ai-overview-refresh.md`.

## Internal linking / orphan rescue
290-post sites often have 80%+ posts with zero incoming internal links ("orphans") — mass-published batches never got linked. Fixing this is a top-ranked SEO lever, but it is a **recurring pass, not a one-off**: the site grows (290 → 304 posts after one cluster publish) and every new article arrives as an orphan. Re-run the audit from a fresh cached fetch before each batch, prioritize orphans with `impressions >= 1` or a 2026 date, cap the plan at ~40 targets, and expect ~35-40% of the set to be linkable per run. Batch 2 (13 Sep 2026): 248 → 193 orphans, 80 links from 57 sources in one background run. **Batch 3 + closing pass (14 Sep 2026): this pass CONVERGES — keep re-running it after each publish, don't report a residual count as the finish line.** Fetch → 154 orphans → plan 284 links from 139 sources (2 links per target, only sources matching **>=2 keyword tokens** of the target slug) → 137/139 sources PATCHed → audit again → 12 orphans left (the articles published *after* the graph was built) → final pass on those 12 (>=1 token overlap, cap 2 links/source) → **2 orphans (1%) of 303 posts, 301 with inbound links**. Report it as `248 → 2 (86% → 1%)`. Full recipe + source-matching rules: `references/orphan-internal-linking.md`. Re-runnable pass (fetch → inlink map → GSC-prioritized plan → one PATCH per source, retry loop for the wedge): `scripts/orphan_rescue_and_thin_audit.py`.

**Zero-link site variant (leitsurabaya.com, 17 Sep 2026)** — when the audit says EVERY post has zero inlinks (41/41), it is not orphan triage, it is a full mesh build, and it doubles as the fix for URLs Google has never crawled. Three passes, one fetch of all posts, dry-run plan printed first: (1) insert a `<h2>Baca Juga</h2><ul>` block 3 links per post before the `\n<h2>Kesimpulan` marker (fallback: append), capped at 5 `<li>` per block; (2) top up the EXISTING block for URLs still light on inlinks (skip when the block is full); (3) inline `<p><strong>Baca juga:</strong> <a>…</a></p>` for laggards that token matching never selected. Every pass is idempotent (`skip if "Baca Juga" in raw`) and verifies each write from the API response. Result: **0 → 214 links**, and the 13 `URL is unknown to Google` posts went to 4-36 inbound links each. Recipe, matching rules and caps: `references/zero-link-internal-mesh.md`.

**Second worked case (TSLaw, 27 Sep 2026) and the interruption lesson.** A 56-article site held exactly **2** internal links in total; one pass inserted a `Baca juga` block (3 links each, picked by title+slug token overlap) into every article that lacked one and finished at **56/56 with outgoing links, 0 failures, top articles at 11 inbound**. Two operational notes: (a) run it `background=True, notify=True` and treat the pass as RESUMABLE — the first run was killed mid-flight by a user redirect, leaving 20/48 done, and re-running the same idempotent script picked up the rest to 56 without duplicating a single block; (b) keep a tiny verification script (`tslaw_verif_link.py` shape: page all posts, count `Baca juga` occurrences + outbound-link count per article, print the histogram) so the final claim comes from a fresh read instead of the run's own log. Back up each article's raw content to `tslaw_pemulihan/salinan_link/` before writing, same rule as every other content pass.

## GSC / GA4 via service account
Service account (`~/.hermes/gsc_service_account.json`, email bambang@gen-lang-client-0311995659) accesses GSC + GA4 + GTM across client properties. Discovery-based googleapiclient (no extra pip libs) works for everything. Setup scopes, key endpoints (searchAnalytics query, URL Inspection, GA4 runReport, dataStreams measurement IDs), and troubleshooting (empty reports, SERVICE_DISABLED, tag dead vs data latency): `references/gsc-ga4-service-account.md`.

## GA4 tag debugging
"GA data stopped on date X for every site at once" across Rocket sites: check THREE layers, in order — (1) **CSP**: identical `default-src 'self'` header with no `connect-src` on ALL client sites (verified 9 Sep 2026, nginx-level, not a WP plugin — plugins endpoint shows no security plugin) blocks every browser connect to Google domains, so NO tag can ever send. Fix = add `connect-src` (Google endpoints or `'self' https:`) to the nginx CSP; one shared include fixes all vhosts. **Count the CSP headers before celebrating: TWO CSP response headers (new one + the old strict one) are enforced as an INTERSECTION, so the old header without `connect-src` keeps blocking GA even after the "fix" was applied.** Remove the old header (`grep -rn "Content-Security-Policy" /etc/nginx/`), keep only the connect-src one, `nginx -t && systemctl reload nginx` — after that (10 Sep 2026) all 8 sites fired collect with their correct per-property tids on the first CDP test. (2) Google Tag `GT-` needs a GA4 **Destination** linked in tag.google.com; `G-` inline config does not. ⚠️ For this client fleet this proved a RED HERRING (10 Sep 2026): once the CSP was clean, all 8 GT-tagged sites fired collect with correct per-property tids on the FIRST CDP test with zero tag.google.com changes — destinations had been linked all along. Diagnose destinations ONLY after CSP is proven clean (reference §1b), not from "GT tag present + 0 data". (3) WP Rocket delay JS — usually a red herring (exclusion fixes loading but collect stays 0 while CSP is up). Definitive probe: headless-chromium CDP test that loads the page, simulates user interaction (Rocket delay loader needs interaction), and reports whether a `collect` beacon fires. Reusable script: `scripts/ga_collect_cdp_test.py`. When head contains a direct `G-XXXXXXXXXXX` config instead of `GT-...`, the Site Kit tag points straight at GA4 (no Destination step) — but that alone is NOT proof of a working tag: Site Kit output has been observed running (client_id obtainable via `gtag('get', …)`) yet firing zero collects. Always inspect the live head with `?nocache=` (WP Rocket serves ~1h-old cached HTML — deactivated plugins' tags still visible on plain curl). If a site's tag runs but never collects, run the in-page fetch probe for CSP first, then prove the snippet with the clean-page isolation test; full ladder + collect-endpoint list + CSP fix value in `references/ga4-connectivity-verification.md`.

**CSP state FLIPS — capture the live header on every audit, never assume.** On 10 Sep 2026 the owner went the opposite way from the whitelist fix and **deleted the CSP include fleet-wide** (verified: all 9 sites → zero `Content-Security-Policy` response headers) because it kept breaking third-party features. GA4 was unaffected by the removal — studiosoft still fired 3 collect beacons with `tid=G-5YGFJFBNH1` and granted consent. So "no CSP header" is a legitimate live state: if the header is absent there is nothing to whitelist and policy can be ruled OUT as a cause; if it is present, check `connect-src` completeness first. Removal trades away XSS hardening — say so once, then follow the owner's choice; to restore protection without re-breaking anything use the full string in `references/csp-third-party-breakage.md`.

## Third-party widget/embed "tidak bisa menyala" (GTranslate, chat, maps, fonts)
Recurring owner report shape: "cek kenapa <fitur> tidak jalan di website X". Diagnose in THIS order — **CSP is suspect #2, not #1**:

1. **Does the integration emit ANY markup?** `curl` the live page (`?nocache=`) and count its fingerprints (GTranslate: `gtranslate`, `translate.google`, `googtrans`, `gt_container`, `img[src*=gtranslate]`). **Zero hits = the plugin/widget was never configured or its widget type was never saved — CSP CANNOT cause this** (CSP blocks *execution*, not tag emission; a blocked script is still visible in the HTML). Fix config first: `WP Admin → Settings → GTranslate → pilih tipe widget → isi domain → Save`, then clear WP Rocket cache.
2. **Only then check CSP.** Symptom split: widget renders but the action fails (frozen spinner / `Refused to connect` in console) = policy; widget absent entirely = config.
3. Whitelist the integration's domains in `connect-src` (and `frame-src` for iframe modes), reload nginx, re-test.

Exact header strings, per-integration domain lists, duplicate-header rule and verification commands: `references/csp-third-party-breakage.md`. Fleet-wide header+widget scan: `scripts/csp_and_widget_probe.py`.

## Verifying GA4/GSC connectivity "by system test" (not by data)
When the owner asks whether website↔GA4/GSC is actually CONNECTED (centang ✅/❌ per site), use probes, not report queries: (1) `<head>` tag state — inline `G-` config = fixed, `GT-` = needs Destination in tag.google.com; (2) headless CDP collect-beacon test (`scripts/ga_collect_cdp_test.py`); (3) GA4 `runRealtimeReport` for instant confirmation (reports lag 24-48h); (4) Measurement Protocol api_secret probe to prove property-side ingestion — **HTTP 204 is NOT proof** (wrong property/secret also 204s silently; verify via Realtime eventName); (5) GSC URL Inspection (siteUrl must match property exactly incl. www/apex — mismatch → 403 artifact). Full recipe + false-negative caveats (consent banners, bot-protected headless, double tagging): `references/ga4-connectivity-verification.md`.

## Conversion tracking end to end ("bikin konversi WhatsApp/form masuk GA4")
The chain has three independent layers and each one can look green while the lead is still unmeasurable — work them in this order and never report "sudah jalan" before the last one:

1. **Is there anything to fire the event?** A site can run a GTM container that is **EMPTY (0 tags)** while GA4 already has the key events defined (`qualify_lead`, `manual_event_CONTACT`, …) — nothing sends them, so 28-day reports show `page_view` and a junk test event only. Confirm both sides before touching code (container contents = Tag Manager API, key event definitions = GA4 Admin API).
2. **Where should the event live?** Prefer the **GTM container** when the site already loads one — the measurement stops depending on a WP plugin surviving theme/plugin changes. Building the tags through the Tag Manager API (empty container → published conversion tags) is in the companion skill: `google-search-console` → `references/gtm-api-tag-building.md`. A WPCode HTML snippet is the fallback for sites with no container, and it must cover the four click paths (anchor `href`, chat-widget element, intercepted `window.open`, `form submit` + AJAX fallbacks) — chat widgets like Chaty do NOT open WhatsApp via `<a href>`, so an anchor listener alone misses the button most visitors press.
3. **Cache layer:** WP Rocket clearing is NOT the finish line on Cloudflare-fronted sites. studiosoft.id serves cached HTML from the CF edge (`cf-cache-status: HIT`, `cache-control: max-age=2592000`), and `?nocache=` is IGNORED there — after any snippet/HTML change, clear WP Rocket, preload, **and purge that URL in Cloudflare**, otherwise real visitors (and your `curl`) keep getting the old HTML while the origin is already correct. Verify by counting the snippet's fingerprint in the live HTML, not by trusting the plugin's "cache cleared" message. **After the owner purges, prove the purge landed with a PLAIN GET (no cache-buster): `cf-cache-status: MISS` AND the new markers in the same response is what a real visitor now receives** — and after the 21 Sep 2026 studiosoft purge that same plain GET was what confirmed both the money page (`#3770` price section + `FAQPage: 1`) and the 28 inbound links on the source articles.

**Honest verification limit — say it out loud instead of running a fifth headless test.** With the container verified (API read-back of the live version) and the dataLayer push verified to reach GTM (the entry comes back carrying `gtm.uniqueEventId`), the only thing left is whether GA4 records the hit — and **GA4 filters automated/headless traffic**, so every self-driven test can come back identically empty no matter how correct the setup is. At that point hand the owner ONE real click from his own device and re-check `runRealtimeReport`; labelling it "container verified, event reaches GTM, GA4 arrival still unproven" is the correct report. Never upgrade that to "conversion tracking works".

## Origin wedge retry pattern
Client origins (103.175.217.138 and friends) wedge intermittently (HTTP 000/404, PHP hang, ~1-3h, self-recover). For multi-step jobs, run a background watcher that polls the API until 200 then executes the queued steps (fetch → audit → apply → verify), instead of hammering synchronously.

**A single-site partial wedge looks HEALTHY from the homepage on Cloudflare-fronted sites (23 Sep 2026).** Triage with the REST endpoint, not the homepage: on studiosoft.id `/` returned `200` in 0,23 s (served from the CF edge cache) while `/wp-json/wp/v2/posts` timed out at 25 s (`000`) and google.com answered 200. Visitors are fine, every read/write is impossible. So before concluding "situsnya sehat, kok batch-nya gagal terus": curl BOTH with `-w '%{http_code} %{time_total}'` — a 200 homepage proves nothing about the dynamic layer. The wedge-waiting watcher can be a plain bash wrapper (`while` poll every 60 s, ~75 min cap → run the queued python pass → log to a file) launched with `background=True, notify=True`; once the API recovers, kill the watcher and run the pass yourself if you are still in-session — the passes are idempotent, but a stray watcher still double-fires.

**Signature of the wedge (verified 12 Sep 2026, full-fleet outage 18:09-? UTC)** — quick triage when EVERY client domain returns 000 at once:
1. `curl -4 -sv https://<domain>/` → TLS handshake COMPLETES, HTTP/2 stream opens, then `Operation timed out with 0 bytes received`. Not a DNS/TLS/local-egress problem.
2. **Port 80 is alive**: `curl -4 -I http://<domain>/` → instant `301` from `nginx/1.18.0 (Ubuntu)` in <0.15s, while 443 hangs. So nginx is up, the dynamic layer (PHP-FPM/DB) is not.
3. **Static vs dynamic matrix** = the clincher: `/robots.txt` may return `200` in 0.07s while `/`, `/?p=1`, `/feed/`, `/wp-login.php`, `/sitemap_index.xml` all hang. Static from disk OK + every WP request hangs = PHP-FPM pool/DB wedged, NOT WP Rocket.
4. **Prove it is global, not our VPS/egress**: `https://check-host.net/check-http?host=https://<domain>/&max_nodes=6` (header `Accept: application/json`) → poll `check-result/<request_id>` after ~30s. All nodes (de/ir/si/us/…) reporting `HTTP 0 … Connection timed out` = origin-side outage, safe to escalate to hosting; a local-only failure means look at our own egress first.
5. Cache-prewarm (`?nocache=`, `wp-cron.php`) CANNOT be triggered while wedged — log the attempt, start the poll-until-200 watcher (`/home/mantapman/wp-rocket-prewarm-watcher.sh`, `MAX_ITER=36 SLEEP=300`), and report the outage instead of the cache status.

## Reader Revenue Manager (RRM) — paywall monetization on a client WP site
Owner started Google RRM (Reader Revenue Manager, subscriptions + contributions) on studiosoft.id to monetize premium content (deep SEO-guide series, AI Personal Assistant series). Setup is UI-only (no public config API) — the owner clicks, the agent prepares everything else. State, pricing, tracker, division of labor: `references/reader-revenue-manager.md`.

## Featured image coverage ("pastikan ada feature image di semua post")
Recurring owner request after a new post goes live. Work order:
1. **Audit first, art second.** Page `/wp/v2/posts` with `_fields=id,title,featured_media,status,date&status=publish` (50/page) and count `featured_media == 0`. Multi-site scan: `scripts/wp_featured_media_scan.py`.
2. **Read the titles of the gap list before generating anything.** On high-count sites the entire "missing" set is usually junk: posts titled `x`, date `2020-01-01` (failed import remnants — studiosoft 1, SSC 29 on 11 Sep 2026, same pattern as the 7 trashed earlier). Those get trashed, not illustrated. Real client sites measured that day: TSC 164/0, DAS 205/0, Yasma 175/0, EveryBe 54/0, TSLaw 54/0 missing.
3. **Generate covers with PIL when image models are quota-blocked** (Gemini image endpoints returned 429 on this fleet — don't stall on that). Match the site by SAMPLING its existing featured images (dominant colour + saturated accent via `colorsys.rgb_to_hls`), then draw 1200×630 (some clients use 1000×567) cards: brand kicker, dark wrapped title, accent bar, domain footer. Working code: `references/featured-image-coverage.md`.
4. **Upload media then set BOTH `featured_media` and the social image metas — where the site registers them.** Yoast falls back to the site logo for `og:image` even with a featured image set, so try PATCHing `_yoast_wpseo_opengraph-image-id` / `_yoast_wpseo_twitter-image-id` to the media ID. **But check the post's registered meta keys first**: some sites expose only 3 Yoast keys (`_yoast_wpseo_focuskw/title/metadesc` — dasindonesia.com, 12 Sep 2026), so the og-image PATCH returns 200 and saves nothing. And when the site has a Yoast **site-wide** default social image, the per-post featured image will NOT displace it through REST at all. In that case stop attempting and hand the owner the 2-click manual path (post editor → Yoast sidebar → Social → Facebook image → Update), while noting the share preview is still on-brand so the impact is cosmetic. Verify by grepping `property="og:image"` on the live post (`?nocache=`) and confirming `wp-post-image` is in the HTML.
5. **When the owner asks for a STOCK photo matched to the subject** ("ambil dari Pexels sesuai merek, minimal logo merek terlihat") follow the sourcing ladder in `references/stock-photo-featured-images.md`: client's own media library → Pexels direct CDN URL by photo ID → Wikimedia Commons API → PIL text cover as last resort. Banked Pexels IDs for the automotive fleet (Nissan 20695196, Mercedes 13718626) are listed there.
   **On studiosoft.id this is no longer "when the owner asks" — real photos ARE the standard (25 Sep 2026).** He rejected the generated text covers outright and picked the search themes himself. Generated text covers drop to true last resort everywhere, and are off the table for this client.

## Near-miss GSC query → new post (when a PAGE holds the keyword)
Before proposing anything, map query→page with GSC `dimensions=['query','page']`. Example (TSC, 11 Sep 2026): `dokter kulit sidoarjo` 130 impressions, pos 7.2, **0 clicks — held by the HOMEPAGE**, which is off-limits. The post-level play: publish a NEW post targeting the exact keyword (direct-answer intro, FAQ block + FAQPage schema, humanist title, then add "Baca juga" internal links from 3-5 related posts that already rank/cluster), and hand the owner title/meta **arahan** for the page separately. Works because a dedicated post out-ranks a homepage for long-tail service keywords.

After publishing a fresh URL, URL Inspection returns `URL is unknown to Google` / verdict `NEUTRAL` — that is expected for a new URL, not a defect. Say so and ask the owner to Request Indexing in GSC.

## Demand-led planning ("apa yang harus ditingkatkan untuk naikkan pengunjung?")
Never answer this from opinion — cluster the GSC query demand, then split **ranking problem vs CTR problem** by comparing position against clicks: pages sitting at pos 1-7 with ~0 clicks are a title/meta job on the page that already holds the impression (`impressions × realistic CTR` = the click upside), while high impressions at pos 20-50 is a content job. "terdekat / near me" clusters are won in the **map pack → Google Business Profile (owner-manual)**, not by more articles. Deliver P1..Pn tagged 🤖 (agent can execute) / 👤 (owner) with expected impact.

**When the owner asks for AUTOMATED Google Business Profile posting** ("bisa ga pasang otomatis post ke google bisnis") — the honest answer shape, the verified 403/404 probe results, the 6 prerequisites Google enforces (verified GBP active 60+ days, website, Organization account, access-request form, then enable), the confirmed fact that Repliz does NOT support GBP, and the interim owner-schedules-it path: `references/gbp-api-access.md`. Never promise automatic GBP posting before the access request clears.

**Cannibalization check before writing anything:** if an existing POST already ranks on the target cluster, expand that post in place (keep the slug) instead of publishing a parallel article — TSC 11 Sep 2026: cluster "kulit kelamin" pointed at a 294-word post 1675, which was grown to 830 words + FAQ schema rather than duplicated. Publish new only when the ranking URL is a PAGE (off-limits) or nothing maps to the query.

**Check the SLUG before POSTing, not after.** `GET /posts?slug=<desired>` first: WordPress silently appends `-2` when the slug is taken, so a fresh long-form article can land on `/bengkel-spesialis-nissan-surabaya-2/` while a 484-word older post keeps the clean URL (DAS, 12 Sep 2026 — the old post was only visible in the GSC cluster view, not in the keyword demand data). Fix: publish under a distinct slug (`bengkel-nissan-surabaya`), add a "Baca juga" link from the old post to the new one, and flag the duplicate pair to the owner for merge/redirect — a 301 needs the owner or a redirect plugin, the agent cannot set it via REST.

Full recipe, TSC worked numbers, batch-of-3 article spec (≈1.300 kata, distinct angle + distinct focus keyphrase per article, word-count check before publishing), publish pipeline and priority template: `references/demand-led-content-planning.md`. Tools: `scripts/gsc_demand_cluster.py` (impressions per theme + CTR per position band) and `scripts/verify_published_post.py` (title/meta/FAQPage/featured/og:image gate on live URLs).

## Keyword gap ("kasih keyword terbaik yang belum ada di <situs>")
Different job from the scoring pass: there the question is *which keyword is worth attacking*, here it is *which demand has no home page at all*. Answer with pages that must be CREATED, and prove each one with a landing-page column, never with intuition. Worked on studiosoft.id 25 Sep 2026 (13 pages + 302 posts, 764 query-page pairs, 490 queries).

1. **Inventory first, then look for the hole.** Pull `pages` + `posts` (publish) and list the fixed pages by hand. The gap question only becomes answerable once you can say which service pages exist.
2. **Check that the existing service pages are real — `content.raw` of the pages, not their titles.** This is the finding that reframed the whole job: `/seo/` (452 chars), `/social-media/` (461), `/web-development/` (464), `/digital-marketing/` (466) were **identical boilerplate pasted four times**, and the text talked about websites even on the SEO page. The site has 302 articles and effectively zero service pages — which is why every money keyword lands on the homepage or an old listicle. Report it as the root cause, ahead of any keyword list.
3. **Landing-page column = the gap detector.** With `dimensions=['query','page']`, a query whose heaviest page is `/` has no dedicated page. That single column produced the whole list (`seo surabaya` 121 imp at pos 51,5 via `/`, `software house surabaya` 15 imp pos 84,6, `perusahaan software di surabaya` 16 imp pos 89,3).
4. **Bucket the queries by service theme** (`seo`, `website`, `iklan/ads`, `sosmed`, `branding`, `konten`, `maintenance`, `aplikasi`, `training`, `konsultan`) and total impressions/clicks per theme; then regex the URL inventory for the same themes. A theme with impressions but no matching URL is a gap.
5. **Validate every gap against what the client actually SELLS — read `/layanan/` before recommending a page.** studiosoft's page lists Google Ads & Facebook Ads and Mobile Apps (Android/iOS) and E-Commerce, so those gaps are legitimate to propose; without that check you would be pitching pages for services the company does not offer.
6. **Rank gaps by demand + proximity, and say which ones to postpone.** The strongest signal is a query already on page 1 with no page behind it (`software custom` pos **2,2**, `software design` pos **7,3**, `konsultan google seo` pos **12,9**) — Google already buys the relevance, there is just nothing to harvest.
7. **State the two limits in the report.** GSC only shows queries the site already appears for, so the impressions you quote are a FLOOR, not market volume (true volume needs Keyword Planner / Google Ads API access). And GA4 cannot prioritise this work: studiosoft's 90 days held **1 `form_submit`, 1 `form_start`, 1 `manual_event_CONTACT`** across the whole site, which is too thin to rank opportunities by conversion.

Worked gap list, method and the owner-facing wording: `references/keyword-gap-analysis.md`.

## Brand work ("cek dari GSC dan GA4, kasih saran untuk angkat brand")
Split GSC queries into **brand vs non-brand buckets** (a per-site brand-token list, `dimensions=['query']`, 90 days) and read the shape, never opinion. Worked numbers 12 Sep 2026: DAS brand 15 queries/1.495 imp/51 klik (CTR 3,4%) vs non-brand 485/5.581/70 (1,25%) — and the brand itself was split across FIVE variants (`pt das indonesia surabaya` 629 imp at pos **5.8**, `das bengkel indonesia` 204/1.1, `das surabaya` 162/**8.3**); TSC brand 12/146/9 vs non-brand 372/**2.912 imp but only 13 klik** (CTR 0,4%). Then: brand query ranking badly on the legal/exact name = **weak entity signal** → consolidate (Organization + `alternateName` + `sameAs`, one page that spells out the legal name); non-brand with near-zero CTR at good positions = **SERP/map-pack problem, not a ranking problem** (fix GBP/ulasan + title/meta, not more articles); GA4 channel **"AI Assistant"** (2 sesi TSC, 12 Sep 2026) = early AI-search referral — structure content for citation (direct answers, FAQ, credentials). Always add GA4 `newUsers` vs `returningUsers` + `sessionDefaultChannelGroup` for loyalty/brand-strength context.

**Brand queries sitting on page 1 with ZERO clicks usually mean the SERP title leads with the KEYWORD instead of the brand** (TSLaw, 27 Sep 2026: `pengacara ts` 48 imp at pos 5,8 · `ts law firm` 36 at pos 6,5 · `tito law firm` 13 at pos 6 · `ts law` 10 at pos 6,3 — all 0 clicks, while the pure-name query `tito suprianto` at pos 2,5 got 3). The live title read `Law Firm Surabaya | Tito Suprianto Law Firm`: someone searching the firm's own name scans the title for the name, does not find it first, and scrolls past. Recommend flipping to brand-first (`Tito Suprianto Law Firm | Law Firm Surabaya`) — but this touches the homepage title, so hand it over as arahan for the owner and do not execute it.

### Rebranding (old name → new name) — ASK before writing
Trigger evidence: Google's own SERP title for the site contains `ex - <old name>` (TSC homepage: *"the skin code clinic | ex - DNI | …"*) and/or GSC still returns the legacy brand queries (`dni clinic krian` 39 imp, `dni clinic` 16, `klinik dni krian` 13). **Never write a migration article from inference alone** — 12 Sep 2026 the agent proposed it from the data, the owner replied "gada migrasi brand", the agent asked one clarifying question (nama lama/rebrand atau entitas lain?) and the owner confirmed "DNI memang nama lama, ini rebranding". One question saved a wrong article; ask first, then execute the play (legacy URL kept + slug preserved, content spine, `alternateName` schema, 3-5 inbound "Baca juga" links, owner-manual GBP description + homepage schema).
Full recipe, both sites' figures and the priority template: `references/brand-entity-rebranding.md`.

## Zero-click CTR rescue & AI-citation batch pass
Companion to the demand-led planner: the recurring execution once query→page pairs exist is a BATCH over existing posts. Select targets from GSC page data (**`clicks == 0`, `impressions >= 3`, position 6-15**) — they already rank and lose purely on SERP appearance; zero-click pages at position 1-5 are a map-pack problem (GBP, owner-manual), not a title job. Per post: humanist hook title + `_yoast_wpseo_title` ≤60 chars + meta 120-158 with the keyphrase EXACT at the start + FAQ(4) with `FAQPage` JSON-LD + a "Baca Juga" link block; posts under ~500 words additionally get ONE substantive H2 section in the same touch. Where GA4 shows an `AI Assistant` channel, add the citation layer: answer-first lead paragraph naming the entity (person + credential + clinic + city), FAQ schema, and a link mesh between the core posts. Run the batch as ONE script with a `JOBS` dict keyed by post id, idempotent guards (skip when `FAQPage` / lead / `Baca Juga` already present) and the wedge watcher at the top, launched `background=True, notify=True`; parallel scripts are safe when they touch different post IDs.
Worked post lists, lead/FAQ templates and the verification gate: `references/ctr-rescue-and-ai-citation-passes.md`.

## Keyword-cluster / pillar build-out ("tambahkan keyword baru", "lanjutkan Pillar A/C/D/E")
This owner asks for strategy in **pillars** and then executes them one pillar at a time ("ok boleh coba Pillar B", "lanjutkan Pillar A C D E"). The recurring five-pillar frame for studiosoft.id and the pillar→deliverable mapping: `references/pillar-cluster-content-strategy.md`.

Order of operations that works:
1. **Classify the money keywords BEFORE writing anything.** Run GSC `dimensions=['query','page']` over the money-keyword set and bucket each keyword by the URL that already ranks. **Posts → agent executes. Pages (homepage/landing/layanan) → write title-meta-content *arahan* for the owner and never touch them.** Worked example 13 Sep 2026: `digital marketing agency surabaya` (198 imp, pos **5.7**) and `digital marketing surabaya` (190 imp, pos 11.7) are held by the HOMEPAGE, while `jasa seo surabaya` (575 imp, pos 27) sits on post 2917 — so the pillar splits into "optimize 3 posts" + "hand over homepage arahan", not "write new pages".
2. **Optimize/expand the posts that already hold impressions** (direct-answer lead, meta 120-158 with the keyphrase at the start, FAQ(5)+`FAQPage`, then verify live) — see the CTR-rescue section for the per-post spec.
3. **Publish the cluster** as 1 pillar + 3-5 supporting articles, each with a distinct focus keyphrase, cover image, tags and category — one script, dict-per-article spec, then the live verify gate: `templates/article_batch_publish.py`. **Give each cluster its OWN cover colour identity** (AI cluster = cyan, branding cluster = amber/gold, 13 Sep 2026) so the archives read as related sets at a glance and the owner can see which build-out a post came from.
   Worked branding cluster (13 Sep 2026, demand straight from GSC): pillar `jasa strategi branding campaign surabaya` (107 imp, pos 15.6 — the near-win), supporting `branding agency surabaya` (181 imp, pos 37), `branding visual surabaya` (66 imp, pos 21.5), `creative agency surabaya` (95 imp, pos 29.7), `digital agency indonesia` (273 imp, pos 70.7) — ~722 impressions of unclicked demand in one five-article set, all interlinked and pointed at the service pages.
4. **Finish with an internal-link pass** from the new posts (and the top-ranked older posts) to the money pages, using the keyword as anchor text (12 posts linked to homepage + `/jasa-seo-professional/` + `/layanan/` in one pass, 13 Sep 2026). Posts carry the anchor; the page never needs to be edited.
5. **Include one data/research article per build-out** (e.g. "Kami audit 290 artikel: 86% halaman 'yatim'") — it is the piece that is actually link-worthy for outreach and the most citable by AI assistants, and it costs nothing new to produce because the audit numbers already exist in this workflow.
6. **Close the build-out by handing the owner a per-property URL list for Request Indexing.** Group the new URLs under the exact GSC property (`https://studiosoft.id/`, `https://www.theskincodeclinic.com/`, `https://dasindonesia.com/`) as a copy-paste code block, mark which are NEW vs merely updated (updated posts usually re-crawl themselves), and warn about the quota: **~10-15 URL inspections per day per property**, so the list is worked over 2-3 days. Offer a follow-up index-status check instead of telling the owner to watch it.

## FAQ / structured data on OWNER-MANAGED pages (homepage, landing)
Recurring owner request after a pillar build-out: *"kasih FAQ terbaik untuk aku pasang di home"* — the agent writes the FAQ + JSON-LD, the OWNER installs it (pages are off-limits, see hard rules). This handoff has four failure modes, all seen on studiosoft.id (13 Sep 2026):
1. **Derive the questions from GSC buying-intent queries, not from imagination.** Pull `dimensions=['query']` 90d and filter for intent words (`berapa`, `harga`, `biaya`, `apa`, `apakah`, `kapan`, `terbaik`, `murah`, `bergaransi`) — the FAQ must answer the objection that is stopping the click (`jasa seo surabaya murah bergaransi` 24 imp → a garansi question; `harga jasa pembuatan website` → a biaya question). 6-8 questions, answer-first, 40-60 words each.
2. **Never paste the JSON-LD/script into chat as the deliverable.** `"@context":"https://schema.org"` was silently mangled in transit (arrived as `"https://***"`), the owner pasted the corrupted string, and the live page ended up with `Question`/`Answer` types but **zero `FAQPage`** (JSON parse error `Expecting ',' delimiter`) — i.e. the FAQ rendered as plain text with no rich result and nobody could tell from the browser. **Ship code as a FILE attachment (`write_file` → MEDIA:), validate it with `json.loads()` before sending, and wrap it in a PHP heredoc** (`echo '<script …>' . <<<'JSON' … JSON . '</script>';`) so the inner quotes cannot clash with the PHP wrapper.
3. **KSES/block-editor strips `<script>` from page content.** After the owner pastes JSON-LD into the page, `content.raw` via `?context=edit` shows **zero `<script>` tags** — a custom-HTML block on a page is not a valid delivery path for JSON-LD. The working path is **WPCode → PHP Snippet → Auto Insert: Site Wide Header**, guarded with `if (!is_front_page()) return;` (site-wide FAQ schema on pages that do not show the FAQ is what Google penalises).
4. **Spectra/UAGB FAQ blocks carry the Astra starter-template schema.** `content.raw` of a page using `wp:uagb/faq` blocks contains a `schema` attribute whose `@id` points at `https://websitedemos.net/astra-blocks/faq-03/` — demo content left over from the theme template. It usually renders nothing, and if it ever renders it is wrong on two counts (someone else's `@id`, someone else's Q&A). Either turn the block's schema option OFF and use WPCode, or replace the block's custom JSON with the site's own — never leave it as-is.
5. **The WPCode snippet's Code Type decides whether `<script>` survives the save.** JSON-LD pasted into a snippet whose type was Text/Universal (instead of **HTML Snippet**) came back as **bare JSON text in `<head>`** — the payload parsed fine and `schema.org` was correct, so every check except "is it inside a `<script>` tag" passed while `FAQPage: 0` and Google saw nothing. And when a PHP heredoc snippet was pasted into an HTML-type snippet, the **PHP source printed as visible text on the live page** (`' . <<<'JSON'` in the public HTML). Signature-to-fix map: bare JSON in head → set Code Type = **HTML Snippet** and re-save; leaked PHP → tell the owner to **deactivate the snippet and clear WP Rocket cache first**, before any other instruction (the page is showing source code to visitors).
6. **Least-fragile path when the owner controls the page: Yoast's own FAQ block** (editor → `+` → **FAQ (Yoast SEO)**) — the owner types the Q&A visually and Yoast emits valid `FAQPage` JSON-LD: no code to paste, so neither the chat-mangling nor the snippet-type trap can bite. Offer this FIRST for owner-installed FAQ; keep WPCode as the fallback with the Code Type named explicitly. Never both (that yields two FAQPage nodes).
   **Confirmed end-to-end 14 Sep 2026 (studiosoft homepage) — this is the closing move for the whole FAQ saga.** After the owner switched to the Yoast block, the live page returned `FAQPage: 1` merged into Yoast's own `@graph` (`@type: ["WebPage","FAQPage"]`, `@id` = the site URL) with 8 `Question` nodes carrying the real questions, while the earlier WPCode attempt's **bare JSON in `<head>` was gone (0 raw-JSON hits, 0 leaked PHP)** — the block also cleans up the failed attempt once that snippet is deactivated. Report both halves (FAQPage present AND no stray JSON/PHP) with `scripts/check_faq_schema_live.py`, then give the owner the two-click close-out: GSC URL Inspection → Request Indexing, plus the optional Rich Results Test.

7. **On POSTS, JSON-LD appended to `content.raw` survives — test it once before building anything.** Pages strip `<script>` (see above), posts do not: leitsurabaya.com 17 Sep 2026 the throwaway FAQPage came back in the LIVE HTML (2 ld+json blocks: Yoast's graph + ours), so 7 money articles got real `FAQPage` schema with no plugin. Parse the Q&A that is ALREADY visible instead of inventing it — one site carried two markup patterns (`<h2>Q: …</h2><p><strong>A:</strong>…` and `<p><strong>1. Pertanyaan?</strong></p><p>jawaban</p>`), so write the parser for both. Strip any previous schema on re-runs so a second pass cannot stack duplicates — **but match the `<script>` tag, not the JSON spacing:** a cleanup pattern keyed on compact `{"@context":"https://schema.org"` silently missed an earlier pass's PRETTY-printed block (`{ "@context": "https://schema.org"`, space after each colon) and left the page serving **TWO `FAQPage` nodes** (studiosoft `#3770`, 21 Sep 2026: 1.603 + 1.454 chars) while my "one block" check passed because it only inspected the block just written. Enumerate every `<script[^>]*ld\+json[^>]*>…</script>` span, keep only the intended one, and verify by counting `FAQPage` across ALL ld+json blocks on the LIVE page — the target is exactly 1, add a visible FAQ section only when the post has none, and validate per URL by counting `FAQPage: 1` in the live HTML. **Schema hygiene:** if an answer claims something the owner says does not exist (LEIT bodies advertised 'persiapan TOEFL dan IELTS' while the owner states there are no IELTS classes), leave that Q&A OUT of the schema and flag the body claim to him — a structured-data claim is the hardest kind to walk back.

**Verify with the FAQPage counter, not with your eyes:** fetch the live URL `?nocache=<v>` and parse every `ld+json` block — target `FAQPage: 1` with the site's own `@id`, or `total schema FAQPage: 0 ❌` while `Question`/`Answer` appear in the type list (= malformed JSON). Three blind spots the probe MUST cover, each one observed live on studiosoft.id: (a) match `<script[^>]*ld\+json[^>]*>` and walk `@graph` — Yoast emits `class="yoast-schema-graph"` and nests FAQPage inside `@graph`, so an exact-tag regex reports "0 blocks" on a page whose schema is fine; (b) **bare JSON in the head** — FAQPage text present but no ld+json script contains it; (c) PHP source or `https://***` leaking into the HTML. Script: `scripts/check_faq_schema_live.py`. Full walkthrough, both install options and the GSC-side follow-up (Rich Results Test, Request Indexing): `references/faq-schema-owner-handoff.md`.

## Panjang artikel — kebijakan yang disepakati (23 Sep 2026)

Jangan pakai satu angka untuk semua artikel. Dasar kebijakan ini adalah data nyata studiosoft.id (307 artikel, GSC 90 hari):

| Kelompok panjang | Jumlah artikel | Impresi rata-rata | Klik rata-rata | % yang dapat impresi |
|---|---|---|---|---|
| di bawah 600 kata | 61 | 1,9 | 0,02 | 39% |
| 600–999 kata | 153 | 29,0 | 0,10 | 66% |
| 1.000–1.499 kata | 72 | 62,7 | 0,74 | 60% |
| 1.500 kata ke atas | 21 | 37,7 | 0,14 | 81% |

Dari 19 artikel dengan impresi ≥100: rata-rata 1.073 kata, median 1.011, dan **9 dari 19 (47%) di bawah 1.000 kata** — yang terbaik 694 kata dengan 545 impresi dan 8 klik.

**Aturan tetap dari Bos (23 Sep 2026 — ini yang berlaku, mengoreksi usulan di atas):**
- **MINIMUM 1.000 KATA untuk setiap artikel baru. Tanpa pengecualian.** Bos memutuskan ini setelah melihat tabel di atas ("kedepan pastikan tidak ada artikel di bawah 1000 kata"). Pita 800–1.000 yang sempat gw usulkan sebagai "cukup untuk artikel pertanyaan sempit" **DIBATALKAN sebagai izin menulis pendek** — artikel pertanyaan sempit tetap ditulis tuntas sampai ≥1.000 kata, bukan dipotong di 850 lalu dibela dengan argumen "topiknya sempit".
- **1.000–1.300 kata = rentang kerja normal**; **1.250+ untuk kata kunci komersial** ("jasa seo surabaya", "jasa pembuatan website") karena di situ bersaing dengan halaman layanan yang tebal.
- **Di bawah 600 kata = zona tipis** (rata-rata 1,9 impresi) — tidak boleh dipakai untuk artikel baru dan jadi prioritas pertama untuk artikel lama.
- **Jangan pernah memaksa panjang dengan kalimat pengisi.** Google menyatakan jumlah kata bukan faktor peringkat; yang dinilai kelengkapan jawaban. Padding menurunkan kualitas dan mudah dikenali. Menambah panjang = menambah bagian yang menjawab pertanyaan lanjutan (cara mengukur, alur kerja, biaya yang terlewat, kapan berhenti), bukan mengulang kalimat.
- **Data pendukung tetap dipakai untuk MEMUTUSKAN, bukan untuk membela pengecualian.** Kalau ada artikel lama yang tampil bagus walau 694 kata (ada: 545 impresi, 8 klik), itu bahan diskusi dengan Bos — bukan izin menulis artikel baru di bawah 1.000.

**Konsistensi angka saat melapor.** Sebut jumlah dan id-nya setelah diurutkan, bukan dari ingatan: sesi 23 Sep 2026 gw bilang "empat artikel di bawah 900 kata" padahal hanya dua (#7531 858, #7532 821 — #7530 ada di 924, #7529 di 1.001). Urutkan daftarnya dulu, baru sebut jumlahnya; kalau salah, koreksi terbuka di balasan berikutnya.

**Cek kepatuhan ke-5 artikel batch sebelum melapor:** satu skrip yang membaca `content.raw` + `meta` tiap artikel lalu mencetak baris per kriteria (kata · meta 120-158 · keyphrase di awal · focus keyphrase · FAQ+schema · featured+alt · kategori · tag · link internal · tombol WA). Dua hasil yang berulang dan WAJIB dicek: (a) **tag sisa dari konten sosial** — kelima draft batch 1 masih bertag `Instagram, Threads` karena naskahnya aslinya caption medsos; tag yang tidak relevan lolos dari semua audit kata/meta dan baru ketahuan kalau tag-nya dicetak; (b) **link internal cuma 1** padahal standarnya 2-3.

## Audit kesehatan SEO seluruh situs (scorecard + program perbaikan)
Dipicu oleh permintaan Bos "pastikan semua artikel sehat secara SEO": jawab dengan SCORECARD berisi jumlah, bukan opini, lalu urutkan programnya. Satu pass membaca `content.raw` + meta + `featured_media` seluruh artikel publish dan mencetak:

```
kata < 1.000            : N artikel
meta description kosong : N
meta di luar 120-158    : N
focus keyphrase kosong  : N
tanpa featured image    : N
tanpa FAQ + schema      : N
tanpa link internal     : N
artikel sehat penuh     : N dari total
```

Hasil studiosoft.id 23 Sep 2026 (307 artikel) sebagai patokan bentuk laporan: **meta/keyphrase/featured image SEMUANYA 0 masalah** (kerjaan pass sebelumnya nyangkut — laporkan sebagai ✅), sementara **kata <1.000: 214 (70%)**, **tanpa FAQ+schema: 267 (87%)**, **tanpa link internal: 65**, dan **hanya 19 artikel sehat penuh**. Pesan yang harus disampaikan: tiga pekerjaan besar, dan **estimasi jujurnya beberapa pekan, bukan beberapa jam** — jangan menyerahkan ini sebagai "beres sebentar lagi".

**Urutan program awal (usulan agent):** (1) sisa draft queue ditulis ≥1.000 kata dulu — ini yang menopang jadwal tayang 08:00; (2) 61 artikel <600 kata dinaikkan ke ≥1.000 + FAQ + link internal, 8-10 artikel per batch; (3) FAQ + schema untuk artikel yang SUDAH ≥1.000 kata (93 artikel — pass tercepat dan paling berdampak untuk kutipan AI); (4) link internal untuk yang belum punya satu pun; (5) 153 artikel pita 600-999 dinaikkan bertahap, prioritas yang sudah punya impresi.

**REVISI BOS 23 Sep 2026 — satu kalimat yang membatalkan langkah (2) dan (5):** *"untuk artikel di bawah 1000 kata tidak usah dikerjakan karena masih mendapatkan impresi. cari yang tidak ada impresi"*. Jadi:
- **Artikel lama di bawah 1.000 kata yang MASIH dapat impresi JANGAN dinaikkan panjangnya.** 70% artikel studiosoft masuk kelompok ini; mengejarnya berarti berpekan-pekan kerja untuk halaman yang sudah bekerja. Kalau ada yang tetap perlu disentuh, alasan harus CTR/posisi (pakai pass zero-click rescue), bukan jumlah kata.
- **Yang dicari: artikel NOL impresi.** Itu program berikutnya — resepnya di bagian "Artikel nol impresi" di bawah.
- **Aturan ≥1.000 kata tetap berlaku mutlak untuk artikel BARU** (draft queue). Yang dibatalkan hanya remediasi artikel lama yang sudah tampil.
- Pelajarannya untuk sesi berikutnya: tabel panjang-vs-impresi tetap dipakai untuk MEMUTUSKAN, tapi keputusan owner bisa menggeser sasarannya ke sisi lain (impresi nol) — tanyakan/konfirmasi sasaran sebelum menjalankan pass massal berhari-hari.

Sebelum menawarkan program, **baca judul artikel tipisnya lebih dulu**: 61 artikel <600 kata di studiosoft.id ternyata semua topik layak (Panduan SEO untuk Klinik, Google Business Profile untuk Klinik, Riset Keyword untuk Pemula, Perbedaan On-Page/Off-Page SEO, AI Overview Google, Guerilla Marketing) — jadi kesimpulannya "semua bisa diperbaiki, tidak ada yang dibuang", bukan "61 artikel sampah". Angka lengkap, bentuk scorecard dan menu perbaikannya: `references/content-health-program.md`.

**Bentuk eksekusi langkah 3 & 4 (dijalankan 23 Sep 2026, dan dua-duanya pass yang paling cepat + paling otomatis dari program ini — kerjakan sebelum yang lain kalau owner menyetujui urutan bebas):**
- **Langkah 4 — link internal untuk artikel yang belum punya satu pun: 64 artikel dapat 2-3 tautan, 0 gagal, satu background run (~10 menit).** Target dipilih dari kemiripan topik (overlap token judul+slug, minimal 2 token), blok `Baca juga` disisipkan sebelum `<h2>FAQ`/`<h2>Kesimpulan`, idempoten pada keberadaan blok. Satu artikel boleh tidak dapat apa pun bila tidak ada tetangga topik yang cukup mirip — laporkan sebagai wajar.
- **Langkah 3 — FAQ + schema untuk artikel yang SUDAH >=1.000 kata: 59 artikel sasaran, dikerjakan 10 per batch, urut impresi GSC.** FAQ ditulis per artikel dari judul + daftar H2 + paragraf pembuka (5 Q&A, 40-60 kata, tidak boleh template generik), lalu pemasang membangun `FAQPage` JSON-LD dari Q&A yang SAMA dan melewati artikel yang sudah ber-FAQ.
- Setelah 74 artikel berubah (64 link + 10 FAQ) **sebutkan purge Cloudflare sebagai langkah penutup** — kalau tidak, perubahan tidak dilihat pengunjung.
Recipe kedua pass (pemilihan target, marker sisip, guard idempoten, gate verifikasi): `references/faq-and-internal-link-batch-passes.md`.

## Artikel nol impresi — triage sebelum menyentuh apa pun (23 Sep 2026)
Dipicu arahan Bos "cari yang tidak ada impresi". Nol impresi bukan satu penyakit, jadi urutannya adalah pemisahan, bukan langsung eksekusi:

1. **Hitung dulu: artikel publish vs halaman yang punya impresi (GSC 90 hari, `dimensions=['page']`).** studiosoft.id 23 Sep 2026: **307 publish, hanya 213 berimpresi → 106 artikel nol impresi (34%)**, plus 16 yang berumur <30 hari. Sepertiga situs tidak terlihat sama sekali — angka ini yang harus disampaikan lebih dulu ke owner, bukan daftar id.
2. **Pisahkan menurut UMUR.** Nol impresi pada artikel <30 hari = normal (belum terindeks), jangan dihitung sebagai masalah. Yang jadi sasaran adalah nol impresi pada artikel ≥30 hari.
3. **Ambil sampel ~18 dan jalankan URL Inspection SEBELUM mengusulkan tindakan** — inilah yang menentukan jenis pekerjaannya. Hasil studiosoft: **15 "Submitted and indexed"** (sudah di indeks Google, tapi tidak pernah ditampilkan untuk kueri apa pun), **2 "Crawled - currently not indexed"**, **1 "Discovered - currently not indexed"**. Jadi mayoritas BUKAN masalah indeks — jangan tawarkan "minta indexing" untuk kelompok terbesar, itu tidak menjawab masalahnya.
4. **Cek POLA TANGGAL TERBIT — ini temuan yang paling menjelaskan.** Nol-impresi di studiosoft mengelompok di hari yang sama: 23 Jul **10 artikel** (klaster klinik/RS, 326-388 kata), 12 Agu **6 artikel** (klaster AI, 1.409-1.753 kata), 30 Jun 6, 17 Jun 5. Terbit sehari sekaligus = kanibalisasi, pola yang sama dengan ambruknya DAS 88%. **Panjang bukan penyebabnya**: klaster AI yang panjangnya 1.400-1.750 kata pun nol impresi. Sampaikan ini ke owner dengan angka per tanggal + judul contoh.
5. **Jangan pensiunkan apa pun dalam 2-4 pekan setelah pass link internal/FAQ.** 74 artikel berubah isinya hari itu (64 link + 10 FAQ); efeknya belum punya waktu tampil di GSC. Ukur ulang dulu, baru putuskan konsolidasi.
6. **Tindakan per kelompok:** (a) klaster batch sehari → **konsolidasi** (1 pilar + 1-2 pendukung, sisanya `draft`, lihat bagian konsolidasi & pruning); (b) `Crawled/Discovered - not indexed` → Request Indexing oleh owner + perkuat link internal, prioritaskan yang panjang dan belum dirayapi sama sekali (studiosoft punya satu artikel 2.392 kata berstatus `Discovered`).

Skrip triage (`ss_cari_nol_impresi.py`), jebakan `context=edit`, dan angka lengkap per batch: `references/zero-impression-triage.md`.

## Meta backlog triage (never mass-rewrite on the literal rule)
A site-wide meta audit will flag ~95% of posts if "focus keyphrase must start the meta" is enforced literally (TSC: 158 of 164 posts, 11 Sep 2026). Split into buckets and only act on the ones that cost clicks:
- **Empty** → write one (120-158 chars).
- **>158** → truncate; this is the real damage, Google cuts it mid-sentence (42 TSC posts fixed in one pass).
- **<120** → expand. A template that clears the floor without hand-writing 38 metas (studiosoft, 13 Sep 2026: 38/38 landed 120-158): `f"{Kw_capitalized}: {clean_title}. Panduan lengkap, praktis, dan bisa langsung diterapkan untuk bisnis Anda. Simak panduannya di sini."`, then the same fallback sentence again if still <120, then truncate at a word boundary if >158. Assert the bound before POSTing, and strip `| brand` suffixes and `&#038;` entities from the title first.
- **Keyphrase not at the sentence start** → SOFT for CTR, but clear it anyway when the owner says "pasang semua": rewrite the OPENING of the meta so it leads with the keyphrase, keep the target keyword + meaning, and never touch the article body or the slug (`10 cara efektif belajar bahasa Inggris otodidak untuk pemula…` → `Cara belajar bahasa Inggris otodidak: 10 tips efektif untuk pemula di rumah. …`). leitsurabaya.com: 22 such posts rewritten in one pass, all landed 135-144 chars. On a 158-post site still triage — say the count out loud instead of silently rewriting the fleet. The alternative cheap fix is to move the FOCUS KEYPHRASE onto the phrase that already opens the meta; prefer rewriting the meta when the existing opening is a hook rather than the target keyword.
Truncate at a word boundary, keep terminal punctuation, and never change the slug during a meta pass.

**Scale observed (studiosoft, 13 Sep 2026):** a 304-post audit returned 163 meta problems — 124 over-length, 38 under-length, 1 empty — i.e. the over-length bucket dominates and is fully machine-fixable in one background pass; the under-length bucket is the only one that needs generated copy. Batch both into one script: truncate first, then generate, then re-fetch a sample from the live HTML to confirm, since Yoast saves through REST meta silently (HTTP 200 with a dropped field is possible on sites that do not register the key).

## Empty-meta fleet sweep ("pasang meta semua artikel, page biar aku")
The shape of this request on a smaller client site (leitsurabaya.com, 17 Sep 2026, 41 posts → 39 updates in one turn). It is a TWO-BUCKET job, not one:

1. **Triage first (never per-post reads):** `GET /wp-json/yoast/v1/bulk_editor/posts?content_type=post&per_page=100&page=N` → bucket **EMPTY** (no `focus_keyphrase` + no `meta_description`; `seo_scores` shows the same count as `notAnalyzed`) and bucket **SOFT** (meta exists, keyphrase not the opening phrase). Cross-check with a `?context=edit&_fields=id,link,title,date,yoast_head_json` post sweep, which supplies `description` + `og_image` + `featured_media` in the same call.
2. **Batch A — EMPTY:** write focus keyphrase + meta 120-158 with the keyphrase at char 0. Read the article before writing: pull `content.raw`, strip tags, and base each meta on the real H1/H2 spine of that post. Same pass absorbs the over-length strays (`>158` → truncate).
3. **Batch B — SOFT:** rewrite the meta opening (see the triage bullet above).
4. **Gate before POSTing:** validate `120 <= len(md) <= 158` AND `md.lower().startswith(fk.lower())` per item, print `OK`/`CEK`, and default to dry-run; only a `--apply` run writes. `CEK` items get fixed by hand — the failing item is usually a hook word wedged between the keyphrase words (`Bahasa Inggris bisa naikkan gaji…` fails for keyphrase `bahasa Inggris naikkan gaji`; drop the `bisa`).
5. **Verify twice:** (a) re-run the bulk-editor audit → expect `kosong 0 / <120: 0 / >158: 0 / fk kosong 0`; (b) fetch a sample of LIVE URLs (oldest AND newest post) and grep `<meta name="description" content="…">` — on LEIT the new meta appeared on the first plain fetch (no `?nocache=` needed), so do not assume cache lag: measure it.
6. **Report to Bos as the compact ✅ matrix** with the before→after numbers (`meta description kosong: 16 → 0`), then hand over the PAGE items as paste-ready strings (title ≤60 chars + meta 120-158 + landscape og:image note) since pages are his.

**The owner's ask "semua artikel" includes the soft bucket.** Do it in the same turn rather than promising a later pass, and keep a per-article table (`id · focus keyphrase · panjang meta · meta description`) as the deliverable file so he can audit the copy without opening 41 URLs.
Re-runnable applier: `templates/meta_batch_apply.py`. Worked numbers, both buckets and the verification transcripts: `references/meta-batch-remediation.md`.

## Thin-content audit & money-slug salvage
Same cached fetch as the orphan audit (`content.raw` → strip tags/scripts → word count). On a 304-post fleet site the pass returned **51 posts under 500 words**, and the top of that list is where the real damage hides — not the long tail:
1. **Read the slug before deciding expand vs trash.** A 5-word post on `/jasa-pembuatan-website/` was a **money keyword holding a clean URL** — the correct action was to write the full ~1.100-word article + FAQ schema INTO that post id (title/meta rewritten, slug untouched), not to trash it and publish a new URL. Rule: clean money slug → expand in place; junk slug + junk body → trash.
2. **Junk detection beats word count** — titles `x`/`xx`, bodies <25 words, `2020-01-01` dates are import artifacts: DELETE (trash) them, they are not content debt.
3. **Expand highest-traffic thin posts first** (intersect the thin list with GSC page impressions); a 300-word post at position 5 outranks a 1.300-word post nobody sees.
4. **Zero-year-stale titles is a real result** — the same audit reported `judul tahun lama (<2026): 0`, which is worth reporting to the owner as ✅ instead of silently omitting it (it proves the earlier freshness passes stuck).
5. **Expansion recipe that works on this fleet** (11 thin posts, 13-14 Sep 2026). Per post: insert **3-4 substantive H2 sections immediately BEFORE the existing `\n<h2>Kesimpulan</h2>` marker** (fallback: before `\n<h2>FAQ`, else append at the end), then append the FAQ block — `<h2>FAQ</h2>` + `<h3>Q</h3><p>A</p>` pairs + an `FAQPage` JSON-LD script — **only when the post does not already have one** (guard on both `"FAQPage" not in raw` AND no existing `FAQ` heading, see pitfalls). Section menus that worked, by article shape: **listicle** → cara memilih [topic] + red flags/tanda bahaya + biaya; **informational/tren** → timeline realistis + kesalahan umum + cara mengukur + cara menerapkan di bisnis kecil; **biaya/harga** → rincian per jenis + biaya yang sering terlewat + cara menghitung ROI. Never pad — every new H2 must answer a buying-intent question already inside that post's GSC query set.
6. **Report the honest post-expansion size.** Posts that started at 349-492 words landed at **604-901 words** after 3-4 sections + FAQ(5) — a thin post does NOT reach 1.300 that way, and the count you quote is the one measured on the rendered live HTML (which is otherwise deflated/inflated by theme chrome). Say "349 → 789 kata + FAQ", never "jadi 1.300 kata".
7. **Shape of the run:** one `JOBS` dict script per 4-6 posts (`{pid: {"add": <html>, "faqs": [(q, a), …]}}`), `cd <workspace> && python3 -u /tmp/<script>.py`, printing `[pid] OK | sections, faq-schema | kata: N` per post so the run is self-verifying, then a prioritised table (id · kata · impresi · posisi) handed to Bos so he picks the next batch. Full worked record — both batches, every section menu, counts, guards: `references/thin-content-expansion.md`.

## Picking the ONE money page among competing posts ("kasih rekomendasi halaman terbaik")
When a commercial cluster has 3-5 near-duplicate posts, the deliverable is a CHOICE, not a list — and it must be gradeable. Worked on studiosoft.id 21 Sep 2026 for `jasa buat website surabaya` (40 queries / 140 impresi / **0 klik**, all money pages at pos 51-90 while informational posts sat at pos 8 ⇒ commercial-page problem, not authority problem):

1. **Score every candidate on four axes before recommending one**: word count (`content.raw`), whether the focus keyphrase is ALREADY the target phrase, 90d impressions/position from GSC page data, and outbound internal-link count (the de-facto hub signal). Winner was `#3770` on all four (1.801 kata, fk already exact, 239 imp, 11 link keluar); `#3483` 839/145/pos 51,3; `#7330` 1.063/235/pos 89,6; `#3496` 596/44.
2. **Prefer the page that is already closest to winning over the cleanest slug.** `#2553 /jasa-pembuatan-website/` had the prettiest URL and **0 impressions** — the intuitive pick, and the wrong one. Say the trade-off out loud ("slug bersih tapi nol impresi") and keep that page as a supporter with a different focus keyphrase.
3. **Count inbound internal links TO the money pages before touching content** — page through all posts and count occurrences of each candidate URL. studiosoft: **739 internal links site-wide, 0 pointing at any of the 5 website pages**, which explains the pos 51-90 floor far better than content quality. A healthy orphan/link graph says nothing about the money pages; measure them separately.
4. **Optimize the winner in this order**: merge duplicate FAQ blocks (this one carried TWO, 4.428 chars of the old pattern) → add a price section using **real figures harvested from the client's own package page** (Rp 3,5-20 juta came from `#3496`; never invent an Rp number on a client money page) → exactly ONE `FAQPage` node → real internal destinations (package page, `/portfolio/` page) → leave an already-compliant 145-char meta alone.
5. **Then the lever that moves position: 0 → 28 inbound links** — one contextual `Baca juga` paragraph into articles chosen by 90d impressions (`#2917` 1.236 imp, `#3007` 556, `#3677` 366 …), exact anchor text, idempotent guard, `background=True, notify=True`. Sibling pages get distinct focus keyphrases (WordPress / biaya / paket / alasan butuh website) and all link to the winner.
6. **Report the baseline to beat** (`239 impresi / 0 klik / posisi 62,4`) and tell the owner the content edit is invisible until Cloudflare is purged on CF-fronted sites.
Full worked table, the copy rules and the dedupe trap: `references/money-page-selection-and-optimization.md`.

## Content consolidation & pruning ("konsolidasi konten tumpang tindih")
The complement of the thin-content pass: instead of growing weak posts, REDUCE their number. Trigger shape — a mass-published site (batches of 7-20 posts per day), a long tail of zero-impression posts, and an owner who says "gabung isi terbaiknya ke halaman utama, sisanya dipensiunkan". Worked on dasindonesia.com 17 Sep 2026: **192 published → 150, two waves, 42 retired, 24 merged first, nothing deleted.**

1. **Map the overlap from data** — all posts with `content.raw` + GSC 90d page impressions; cluster by topic; flag zero-impression posts (46 of 192), same-day same-topic batches (7 Alphard on one day), `-2` slug suffixes (11, one a byte-copy), and inflated clusters (4 Brio pages).
2. **Three buckets, printed as a dry-run plan first:** KEEP (has impressions / distinct angle / **a new money page whose 0 impressions are a 404 artifact — not a prune candidate**), MERGE+RETIRE (substantive but same angle), RETIRE (thin, generic, off-topic, duplicate).
3. **Merge only what is genuinely new** — parse the source into `<h2>` blocks, move ONLY headings the target lacks (skip FAQ/Kesimpulan/Baca Juga), **cap 2 blocks per source**, insert before the target's FAQ/Conclusion marker. Never move the source's FAQ block.
4. **Retire by TRASH — owner rule 23 Sep 2026: *"artikel yang pensiun letakkan di trash jangan di draft"*** (`DELETE` = trash, recoverable; never `force=true`). The retired URL then 404s live — that is intended, not damage. Then hand the owner a redirect list built from **slug** (the trashed slug carries a `__trashed` suffix and its REST `link` is useless to him) — REST cannot create redirects, so that step is his.
5. **Verify**: re-read every touched post (`?context=edit`) for status + word count, count by status with paging (expect `PUBLISH 150 | DRAFT 45`), render-check 2-3 mains live. Expect one leftover: a source whose sections were ALL duplicates never enters the merge branch, so it must be retired explicitly.

**Wave-by-wave variant (topic cluster, one "lanjut" per wave):** when the trigger is a SINGLE cluster with 8-20 same-angle posts that all already sit at position 5-12 with tiny impressions (yasmanatura.co.id klaster serbuk, 23-24 Sep 2026: **82 → 53 in 7 waves**, ending with 9 pillars each owning one topic — #2729 payung, #3696 peluang bisnis, #3475 maklon serbuk, #3420 cara memulai, #3416 tren & rasa, #3698 legalitas, #3346 MOQ, #2793 kenapa memilih Yasma (2.342 kata), #3037 ide produk), the signature is *split demand*, not weak ranking — so consolidate, never add. Pick the pilar by highest impressions (not the prettiest slug), keep 0-2 genuinely distinct angles as supporters with links, trash the rest, and report the running count each wave. Two traps that cost real quality here: **merging must APPEND to the pilar's existing body, never replace it** (wave 6 nearly swapped a 1.244-word pillar for a 610-word rewrite), and **the FAQPage schema must be rebuilt to cover every Q&A visible on the page**, not just the newly added ones (17 visible, 5 in schema).

Full recipe, bucket rules, merge caps, worked growth numbers, the wave variant and all pitfalls: `references/content-consolidation-and-pruning.md`.

## Site-wide CTA button at the end of posts ("kasih WhatsApp Button: Konsultasi Gratis, warna primary + white text")
Recurring owner ask just before an Ads push: one conversion button at the bottom of the LATEST articles ("lakukan untuk 5 artikel terakhir"). Deliver it as one verified block injected into `content.raw`, never as code for him to paste.

1. **Read the brand colour out of the theme CSS — never invent one.** `curl` the homepage and grep the inline theme stylesheet: Astra writes the button rule as `background: var(--ast-global-color-0)` with `color:#ffffff`, so **`--ast-global-color-0` IS the primary** (studiosoft.id = `#6878d6`, hover `#4d61d6` = color-1). Say which variable you read it from in the report; the owner judges colour by eye and a near-miss shade is instantly visible next to the theme's own buttons.
2. **Target list = a REST query, not a guess.** `GET /wp/v2/posts?per_page=5&orderby=date&order=desc&_fields=id,link,title,date`. "5 artikel terakhir" on studiosoft.id 21 Sep 2026 spanned 13-20 Sep (four from 17-20 Sep + one from 13 Sep), so page the response rather than assuming a publish cadence.
3. **Append to the END of `content.raw`** and PATCH `{content: ...}`; guard idempotency on the block's own class (`skip if "ss-cta-wa" in raw`).
4. **Reuse the site's own wa.me number** (studiosoft `6281935000624`, the one in the header CTA) with a prefilled `?text=` — the exact URL shape the conversion snippet matches, so the button registers as `manual_event_CONTACT` the moment tracking goes live. Say that to the owner; it is why the button is worth placing before the Ads campaign, not after.
5. **Verify on the live pages, then state the cache caveat.** One loop over the target URLs counting the block markers, then "purge Cloudflare before visitors see it" on CF-fronted sites.
6. **"pastikan SEMUA artikel ada WA Button" turns the same block into a full-site sweep — an hours-long background job, not a quick pass.** studiosoft.id 21 Sep 2026: page `?per_page=100&context=edit` → **307 publish, 5 already done, 302 patched** at ~5-6 s per post (~10 posts/min, ~45 min total), one POST + one re-read per post. Launch `background=True, notify=True`, print `progres i/N | sukses x | gagal y | menit` every 20 posts so an interim "gimana?" from the owner gets answered with real numbers instead of a promise. Expect a few **transient `404 Not Found` mid-run** (wedged origin — ids #3680/#3677 that day): collect them and retry at the END rather than aborting the batch, then re-page everything and assert `belum ada tombol: 0`. The `<style>` block repeats once per post (harmless, ~400 bytes) and `ss-cta-wa` stays the idempotency marker, so a re-run is a no-op. Remind the owner the whole sweep is invisible to visitors until Cloudflare is purged.
Block, brand-colour discovery commands and the verification loop: `templates/whatsapp-cta-button.html` and `references/cta-button-injection.md`.

## Owner-managed page SERP handoff (judul / meta / H1) — "kasih aku ini nanti aku input manual"
The CTE-side counterpart of the CTR-rescue pass: when the keyword is held by a PAGE, the deliverable is **paste-ready strings + a verification promise**, never an edit. Worked on the studiosoft.id homepage 23 Sep 2026.

1. **Map query→page FIRST and count which side owns the keywords — otherwise you promise work you cannot do.** Of the 5 priority local keywords, **3 were held by the HOMEPAGE** (`digital marketing agency surabaya` pos 5,9 · `digital agency surabaya` pos 9,9 · `digital marketing surabaya` pos 10,6) and only `jasa seo surabaya` (575 imp, pos 28,8) + `branding agency surabaya` (pos 48,8) sat on posts. Leading with "gw optimalkan 5 keyword" was a false promise; the honest split was 3 to the owner, 2 to the agent. Say the split out loud before proposing anything.
2. **Quantify with the site's OWN CTR curve by position band, not a generic CTR table** — `scripts/ctr_band_curve.py` returns it in one query (`pos 11-20: 829 imp / 0,97 %` vs `pos 21-50: 2.912 imp / 0,38 %` on studiosoft). Then translate "pindahkan ke halaman 1": the impressions already exist, the page-level work (title relevansi, kedalaman, FAQ, link internal, kecepatan) is what moves position, and no NEW article is needed — on a 307-post site a new article only splits the signal further. Expect this question; the owner asked it plainly (*"maksudnya ini apa?"*) and the answer that landed used his own band numbers.
3. **When title AND meta are already keyphrase-correct, check the H1 next — it is the most common hidden CTR cause.** studiosoft's homepage title already began with the keyphrase and the meta was a compliant 139 chars, yet CTR sat at 1,2–1,4 % at pos 5,9–10,6 — because the H1 read **"Solusi Digital Terbaik untuk Bisnis Anda"** with zero keywords (Google may use the H1 as the SERP title). Inspect the live `<h1>` before rewriting copy that is already fine, and hand over H1 as a third item alongside title and meta.
4. **The handoff package = 3 items + the Social tab, each with a character count.** Recommend ONE primary per item plus 2-3 alternatives for him to A/B (title ≤60 with the keyphrase at char 0; meta 120–158 with the keyphrase at char 0; H1 matching the title's keyphrase). Add the Yoast **Social** tab (og:title/og:description) so WhatsApp/Facebook previews change with it, name where each field lives (Yoast panel for title/meta, the hero block for the H1), and state **do not change the slug**.
5. **Verify each element separately when he says "sudah"** — one plain `GET ?nc=<ts>` asserting `<title>`, `name="description"`, `property="og:title|og:description"` AND `<h1>`, then a plain GET reading `cf-cache-status: MISS` on CF-fronted sites to prove visitors get the new copy. Report as a ✅ line per element (that is the compact matrix he expects), and record the pre-change baseline.
6. **Lock the baseline and schedule the measurement instead of asking him to remember.** Snapshot the target keywords' 28-day impressions/clicks/CTR/position into the file, and plant a weekly cron (`--no-agent`, silent until impressions clear a floor) that re-pulls and compares against that snapshot — CTR moves 2–4 weeks after Google shows the new copy, and "kalau tidak bergerak, masalahnya bukan di judul" is the commitment worth making.
Worked strings, baseline table and the cron shape: `references/owner-page-serp-handoff.md`.

## Draft queue → tayang otomatis harian ("tayangkan semua draft tiap hari jam 8 pagi")
Jangan jawab dengan cron — WordPress punya penjadwalnya sendiri (`status: "future"` + `date` lokal situs, satu artikel per hari). Yang menentukan hasilnya adalah **triage antrean sebelum menjadwalkan**, karena draft queue hampir selalu di bawah standar: studiosoft.id 23 Sep 2026 punya 26 draft dengan **76-133 kata, meta 0/26, keyphrase 0/26, featured image 0/26, FAQ 0/26, link internal 0/26** — menjadwalkannya apa adanya = memproduksi 26 halaman tipis, pola yang menjatuhkan DAS. Order yang bekerja: (1) satu GET `?status=draft&context=edit` → hitung kata/meta/keyphrase/image/FAQ per item; (2) **cek duplikat judul lebih dulu** — #7548/#7549/#7550 ternyata salinan persis #7528/#7529/#7530, dibuang ke trash; (3) tunjukkan angkanya ke owner dan tawarkan pilihan (lengkapi dulu / tayang apa adanya / gabung jadi satu halaman FAQ), jangan langsung eksekusi; (4) lengkapi ke standar **dalam satu pass tulis** (pelajaran ini terulang lagi: draft cepat keluar 411-693 kata dan butuh tiga putaran tambahan untuk mencapai ~1.076 — tulis 9-10 H2 sejak awal); (5) jadwalkan `{status:"future", date:"YYYY-MM-DDT08:00:00"}` berurutan satu per hari, lalu verifikasi lewat read-back (`status/date/featured_media/meta/content.raw`); (6) **artikel `future` masih bisa di-PATCH sebelum tayang** — pakai itu untuk menjadwalkan lebih dulu lalu menebalkan isinya; (7) laporkan jumlah kata dari `content.raw` yang TERSIMPAN (lebih tinggi dari angka dry run karena sudah termasuk FAQ+schema+CTA) dan sebutkan terus terang mana yang masih di bawah 1.300.
Recipe lengkap, angka hasil dan gate verifikasinya: `references/draft-queue-scheduling.md`.

## Situs klien kena injeksi spam judi / SEO-spam ("cek lalu bersihkan")
Trigger: Bos menyerahkan kredensial WP klien lalu bilang "tolong cek" / "delete situs judi onlinenya, kamu cari n bersihkan". Yang dihadapi bukan satu-dua artikel nakal, tapi **blog klien yang dikuasai spam judi**. Klinik Metamorf (klinikmetamorf.com), 24 Sep 2026: **185 artikel spam = SELURUH blog**, 160 kategori + 4 tag judi, **0 artikel klinik tersisa**, semuanya terbit 17–24 Sep oleh **author ID 1 (akun admin)** — indikasi kredensial admin bocor, bukan registrasi terbuka.

**Jangan percaya classifier berbasis judul.** Pass pertama dengan daftar kata judi hanya menangkap 180 dari 185; sisanya memakai nama brand yang tidak ada di daftar mana pun (Mostbet, Дэдди Казино, Push Gaming, aviator, plinko, pin up, vulkan vegas, sweet bonanza) atau judul netral berbahasa Inggris ("How to Evaluate Information on the Internet" yang isinya taruhan). Klasifikasi yang bekerja = tiga sinyal sekaligus: (a) penanda di ISI, (b) link keluar ke domain kasino, (c) **JENDELA TANGGAL SERANGAN**. Histogram tanggal SELURUH post lebih dulu: kalau semua post situs jatuh dalam rentang satu pekan dan tidak ada konten lama, seluruh blog adalah konten penyerang dan tidak perlu dipilah per judul.

**Urutan pembersihan yang bekerja** (dry-run dulu, selalu):
1. Satu sweep `?context=edit&_fields=id,title,date,content,categories` dengan paginasi → cetak per post jumlah penanda + link keluar sebagai bukti, jangan hanya hitungan.
2. Post → `DELETE /wp/v2/posts/<id>` (masuk Trash, bisa dipulihkan). Owner bilang "delete", tetap trash dulu dan sebutkan itu di laporan.
3. Kategori & tag → **WAJIB `?force=true`** (`DELETE /wp/v2/categories/<id>?force=true`) karena term tidak mendukung trash.
4. Verifikasi ulang: publish 0, kategori tersisa `Uncategorized`, tag 0, halaman asli utuh.

**Tiga jebakan yang benar-benar menggigit:**
- **Paginasi term: `per_page=100` itu BATAS, bukan total.** Menghapus "halaman pertama" menyisakan sisanya — **63 dari 160 kategori judi masih hidup** setelah pass pertama dan baru ketahuan saat verifikasi (total sebenarnya 160, bukan 100). Loop `page=N` sampai halaman pendek, lalu hitung ulang setelah apply.
- **Kata judi di HTML halaman depan sering FALSE POSITIVE.** 20 kemunculan "casino/slot/bet" di beranda klinik ternyata CSS/JS page builder (`justify-content:space-between`, `.vc-spinner`). Cetak 130 karakter sebelum dan sesudah tiap kemunculan sebelum melapor "halaman depan kotor".
- **Halaman (`post_type=page`) biasanya utuh** (6 halaman asli 2018–2019 di kasus ini: Home, Layanan, Gallery, Tentang Kami, Beranda, Privacy Policy). Periksa terpisah, jangan ikut dibersihkan, dan laporkan sebagai aman.

**Triage backdoor dari luar (yang BISA tanpa akses hosting):**
- `GET /wp/v2/plugins` + `/wp/v2/themes` → inventaris lengkap dengan status. Temuan yang paling berbicara: **Sucuri Security, Solid Security, WP Rocket, Yoast SEO terpasang tapi NONAKTIF** — pola khas pasca-pembobolan (plugin keamanan dimatikan penyerang). Laporkan sebagai bukti, bukan daftar biasa.
- `GET /wp-json` → namespace tak dikenal = plugin asing (`qode-api/v1` = bawaan tema Bridge, aman).
- `content.raw` tiap halaman → cari `<script>`, `<iframe>`, `base64_decode|eval(|gzinflate|str_rot13`, host luar (iframe Google Maps = wajar).
- Probe luar: `/wp-content/uploads/*.php` (404 = bagus, uploads tidak mengeksekusi PHP), `/.env` (404 bagus), `/wp-content/debug.log` (404 bagus), `/wp-config.php.bak` (403 = diblokir, aman), `xmlrpc.php` (**405 = AKTIF**, minta dimatikan), `/readme.html` (200 = membocorkan versi WP), `/wp/v2/users` (200 = enumerasi username).
- **Batas jujurnya: `wp-config.php`, `.htaccess`, dan mtime file tidak bisa dibaca dari luar.** Sampaikan apa adanya dan serahkan ke pemilik (SSH/cPanel/Wordfence). Jangan mengklaim sudah scan file.

**Handoff pemilik** (daftar berurutan, dia yang eksekusi): ganti password WP/hosting/FTP/DB/email → 2FA → scan memakai plugin keamanan yang sudah ada (tinggal diaktifkan) → update core + tema + plugin → hapus tema/plugin tak terpakai → tambahkan properti ke GSC, cek **Security Issues & Manual Actions**, ajukan review setelah bersih. Sebutkan 185 post berada di **Trash** (masih di database) dan tawarkan pengosongan permanen setelah dia selesai memeriksa.
Recipe lengkap, daftar probe dengan respons yang diharapkan, dan bentuk laporannya: `references/wp-spam-injection-cleanup.md`. Script generik (dry-run default, paginasi aman): `scripts/wp_spam_sweep.py`.

## Editing live pages safely + recovering a page you broke
Before changing the `content` of any live page, read `references/wp-content-recovery-and-safe-editing.md`.
Short version: content writes go through REST (never XML-RPC), always save the raw content to a local file
first, and re-read the stored length afterwards. That file also carries the recovery order (revisions →
autosaves → origin cache files → Cloudflare → web archives → hosting backup) and the rebuild-from-sibling-pages
recipe used on TSLaw after the homepage was emptied.

## Deciding what to write next
`references/keyword-gap-planning.md` — the GSC query+page method for finding keywords with demand but no page,
the "audit whether the pillar page is actually filled" step, and the owner's rules (skip keywords that already
have a page; 1,000-word minimum; one article per day).

## Featured images for new articles
**A real stock photo, never a generated text-graphic.** The owner's instruction was "stop generate text gitu"
after seeing a PIL-rendered title card; a photo of a monitor, laptop, website or the article's subject is what
he wants. Scale to 1000×567 WebP q90 to match the existing article images.
When the Pexels API keys in `pexels_image.py` return 401, this path works: read photo IDs from
`curl -4 "https://r.jina.ai/https://www.pexels.com/search/<query>/"` (IDs appear as `pexels-photo-<id>`), then
download `https://images.pexels.com/photos/<id>/pexels-photo-<id>.jpeg?auto=compress&cs=tinysrgb&w=1200`,
centre-crop to 1000×567, and upload with the `Content-Disposition` header. Look at the crop before attaching it
— one candidate that read as "online store" was actually an ad-spend dashboard, and the swap had to be redone.

## Publishing a new article that matches the site standard (StudioSoft, proven 25 Sep 2026)
Copy the last published post's structure instead of inventing one: fetch `context=edit`, extract the CTA `<style>`+markup, the FAQ `<h3>/<p>` pairs, the `application/ld+json` FAQPage, and the "Baca juga" paragraph. A post that ships with all four reads as native.
- CTA block (StudioSoft): `class="ss-cta-wa"`, bg `#6878d6`, hover `#4d61d6`, `wa.me/6281935000624`, closing line "Dibalas langsung oleh tim StudioSoft…".
- FAQ: 5 Q&A visible as `<h3>/<p>`, then `<script type="application/ld+json">` with matching `mainEntity` (validate with `json.loads` before sending).
- Yoast writes over REST do work here: `meta: {_yoast_wpseo_focuskw, _yoast_wpseo_title, _yoast_wpseo_metadesc}` saved on first POST. Keep metadesc ≤158 and verify by re-reading, not by trusting the 201.
- Schedule with `status: future` + `date: YYYY-MM-DDTHH:MM:SS` (site timezone Asia/Jakarta). Check the existing `status=future` queue first — StudioSoft already occupies a daily 15:00 WIB slot, so extra same-day articles need a different hour (19:00 used).
- Count words on the RAW content with scripts/JSON-LD stripped: the schema and CTA text inflate naive counts (1.100-word article reported as 1.967).
- Featured thumbnail: **a real Pexels photo, never a generated text card.** Owner correction 25 Sep 2026: after two articles shipped with PIL title-cards he said *"untuk website stop generate text gitu"* and then named the search themes himself (monitor / website / wordpress for the maintenance article, "google ads" for the ads article). Crop to **1000×567** (the size the site's existing covers already use), WebP q90, filename `<post-slug>.webp`, alt = a plain description of what the picture shows. Sourcing, subject-verification and swap recipe: `references/stock-photo-featured-images.md`.

Scripts: `ss_artikel_baru_20260925.py` (template with CTA + FAQ schema + upload + scheduling + verification), `ss_verif_artikel_baru.py` (post-publish audit), `ss_celah_keyword2.py` (gap finding: queries landing only on the homepage = no dedicated page).

## Artikel listikel ("kasih artikel tentang 10 <profesi> terbaik untuk <topik>")
Bos asks for these in SERIES — *"kita perbanyak artikel listikel"* (25 Sep 2026, TSLaw). Treat it as a recurring content class with a repeatable recipe, and expect follow-up requests on other topics.

The shape, every time: a numbered list of `N` practitioners for ONE topic in ONE city, with **the client at number 1 and a link from that entry to the client's homepage**. When he writes "(angka)" he is leaving the count to you — pick it and say which number you picked.

**The editorial rule that matters: never invent competitor names, and never write unverifiable "terbaik" claims about them.** Fill positions 2..N with **real institutions and roles** — for legal topics: LBH (bantuan hukum gratis), Posbakum at the Pengadilan Agama/Negeri, the advocate organisation (Peradi) for checking a licence, notaris & PPAT for deeds and land, certified mediators, specialist-role advocates (pendamping KDRT), estate/warisan consultants, and the court itself where a suit is filed. Give each entry a "cocok untuk" line so the list reads as guidance instead of a ranking of strangers, **state the choice plainly in the report**, and offer to insert real firm names if he supplies them. That is what he accepted.
Why: local SERPs cannot be verified from this VPS for Indonesian queries (Bing returned Zhihu/Wikipedia for "pengacara gugat cerai surabaya"; Startpage empty, Brave 429, Ecosia 403), and a fabricated ranking on a client's site is a reputational and legal risk rather than an SEO win.

**Body that clears the ≥1.000-word rule on merit** (listicles tempt padding): explain the underlying basis first — TSLaw used UU No. 1/1974 + PP 9/1975 for cerai gugat, and jenis wasiat (olografis/terbuka/rahasia) + syarat sah under KUHPerdata for surat wasiat — then "cara memilih", "dokumen yang perlu disiapkan", "kesalahan yang sering terjadi", the numbered list itself, and a closing "Baca juga" block. Read the client's OWN pillar posts on the topic first and source facts from them, so no claim on the site contradicts another.
Per-article standard is the same as any other article: WA CTA button, FAQ 5 + FAQPage schema, meta 120-158 with the keyphrase at char 0, category + 4 tags, a real Pexels photo, 2-3 internal links (homepage + the topic's pillar posts).

**Publish flow that worked on TSLaw (25 Sep 2026):** create as `draft` → verify by read-back → THEN set `status: publish` → re-fetch the live URLs. TSLaw specifics: Yoast keys are NOT registered in REST there, so `meta` in the POST body is silently dropped — create the post via REST (title/slug/content/category/tags/featured_media), then write `_yoast_wpseo_focuskw/_yoast_wpseo_title/_yoast_wpseo_metadesc` via XML-RPC (on a fresh post there is exactly one row per key, so a plain add is correct; on an EXISTING post those rows must be updated, not added).
Live-verify gate per URL: entry no.1 names the client AND carries `href` to the homepage, word count on the raw content, `FAQPage: 1` across the ld+json blocks, meta length, and a phone-width render check for overflow.
Worked example — both articles, IDs, counts, photo IDs and the exact report shape: `references/listicle-articles.md`.

**Cadence: one listicle every 2 DAYS, not daily (owner rule 27 Sep 2026).** He first said "lakukan tiap hari", then corrected the POSTING rhythm: *"untuk posting artikel pastikan tiap 2 hari sekali"*. Treat the two as different things — daily PROGRESS (some SEO work every day), one post every 48 hours. Before writing the next one, anchor on the DATE OF THE LAST POST THAT ACTUALLY WENT LIVE (read it from the site, not from your plan or from memory), because a hand-built schedule drifts the moment a session is skipped: the TSLaw queue written for 26 Sep was already two days stale when it was next opened. Re-date the whole table rather than resuming mid-list.

**Run the series from a cron job with a DRAFT gate, not by hand each time.** Bos asked for the series to keep moving without prompting every run, and after the TSLaw content-loss incident unattended writes to a client site need a review step. Shape that worked: `hermes cron create --name "tslaw-listikel-2hari" --deliver origin --continuity "<schedule>" "<prompt>"`, where the prompt carries the whole article standard inline (posts only · ≥1.000 kata · entri no.1 klien + tautan beranda · lembaga nyata bukan pesaing karangan · CTA WA · FAQ 5 + schema · kategori + 4 tag · foto Pexels · meta Yoast · REST bukan XML-RPC · simpan salinan isi dulu · **buat sebagai DRAFT, jangan publish** · lapor ke chat: judul, URL draft, jumlah kata, keyphrase, kelengkapan aturan). `--continuity` is not optional here: it is what makes each run see the previous run and move to the NEXT topic instead of repeating the same one. Schedule format traps: cron runs in UTC (09:00 WIB = `0 2 * * *`); when the cadence changes, `hermes cron edit <id> --schedule "every 48h"` works but an interval schedule is anchored to the moment you edit it, so the delivery hour shifts to that moment, while `0 2 */2 * *` keeps 09:00 WIB but drifts by a day at month ends. Pick one and say which you picked.

**Deliver the calendar and the target-keyword map as FILES, not chat prose.** This ask came as "buatkan jadwal untuk dilakukan per harinya" plus *"intinya target akhir harus SEO dengan beberapa kata kunci yang paling banyak dicari atau paling bagus di Surabaya"*, so the deliverable is two files: (a) a schedule table `tanggal · judul · slug · keyphrase` plus the standing article rules, and (b) a TIERED target-keyword map — **Lapis 1** money keywords the client must reach page 1 for (with impressions, position and which URL already holds each), **Lapis 2** brand queries (already page 1 — check the CLICKS, not the position), **Lapis 3** topics already proven to bring GA4 sessions, **Lapis 4** gaps with demand but no winning content. Add the measurement plan (what to re-pull after each article) and repeat the honest limit in the same report: GSC impressions are a FLOOR, not market volume — real volume needs Keyword Planner / Google Ads API access this project does not have. Never let a tier list read as if market demand had been measured.
Recipe, cron commands, the prompt text and the TSLaw keyword map: `references/content-series-cadence-and-cron.md`.

## Quick site audit checklist (30 minutes, no owner needed)
Use when the owner says "cek saja situs X". Cheapest decisive checks first.
1. **HTTP & redirect:** apex vs www, chain length (more than one hop is a finding), SSL issuer + expiry, TTFB.
2. **Speed:** time 3-5 sequential loads of the homepage. Anything over ~3s on an HTML origin is a headline finding, and report it in seconds, not adjectives. Note `cf-cache-status` (DYNAMIC = HTML not cached at the edge) and payload size (TSLaw: 862 KB HTML, 13 CSS, 36 script blocks, 33/33 images with no `loading="lazy"`).
3. **WordPress:** site name, timezone offset (empty/UTC on an Indonesian client means scheduled posts shift 7 hours), plugins active vs inactive (dormant security plugins are a finding), page-builder version (beta on production is a finding).
4. **Install leftovers:** `/sample-page/`, `/hello-world/`, `/?p=1`. Trash them, then confirm the URL answers 404.
5. **Per-page coverage:** pull GSC page rows and flag pages with impressions and **zero clicks while sitting on page 1-2** — that is a snippet problem, and the usual cause is an empty Yoast title/meta (TSLaw 25 Sep: four pages had none, including `/tentang/` at position 6,7 with 85 impressions).
6. **Conversions:** list GA4 event names for 90 days. Zero `form_submit` or outbound-click events on a lead-gen site means leads are invisible; check the "Enhanced measurement" toggles before writing any snippet.

## robots.txt untuk situs klien (Yoast)
Tidak ada jalur API: robots.txt dilayani WordPress core + filter Yoast, jadi `wp.setOptions` maupun WPCode tidak menyentuhnya. Deliverable-nya **file + arahan pemasangan** (`templates/robots-txt-wp-client.txt`, dikirim sebagai lampiran) ke folder WordPress (bersama `wp-config.php`) atau Yoast → Tools → File editor; file fisik menimpa versi virtual. Isi yang dipakai: blokir `/wp-admin/` (kecuali `admin-ajax.php`), `/wp-login.php`, `/?s=` + `/search/`, `/feed/`, `/xmlrpc.php`, `Allow: /wp-content/uploads/`, pertahankan baris `Sitemap:`. **Jangan blokir `/author/` selama `author-sitemap.xml` masih ada di sitemap index** — urutannya: matikan arsip penulis di Yoast → verifikasi sitemap index → baru aktifkan barisnya. \"Sudah saya pasang\" tidak sama dengan sudah berlaku: TSLaw 27 Sep 2026 masih menyajikan versi lama 170 karakter dengan `cf-cache-status: HIT` — bedakan cache Cloudflare (minta purge; trik query cache-buster dibalas **403**) dari salah folder, dan laporkan apa adanya. Detail + tabel alasan per baris: `references/robots-txt-client-sites.md`.

## Pemilik mengganti slug → sapu tautan internal yang jadi 404
Slug halaman klien bisa berubah tanpa pemberitahuan (TSLaw 27 Sep 2026: `/practice-areas/` → `/layanan/`, halaman tetap publish, hanya slug yang berubah) — dan yang paling kena adalah tautan di artikel yang baru Anda tulis. Deteksinya sering datang dari verifikasi lain: **selalu sertakan satu URL halaman tetap di daftar uji mana pun**. Urutan: pastikan bukan kerusakan (`pages?context=edit`) → cek `/page-sitemap.xml` (slug baru masuk = perubahan disengaja) → hitung string slug lama di semua `content.raw` → simpan salinan isi dulu → ganti lewat REST JSON → verifikasi dari HTML live (jumlah tautan baru + tautan beranda tetap ada) → serahkan pengalihan 301 ke pemilik (REST tidak bisa membuat redirect). Recipe + hasil TSLaw: `references/slug-change-link-sweep.md`.

## "Kenapa tampilannya tidak responsive di mobil / bisa di-zoom out?"
Kalau meta viewport benar (biasanya benar), penyebabnya elemen yang meluber. Ukur di lebar 390px dengan Playwright: `documentElement.scrollWidth` vs `innerWidth`, lalu urutkan elemen terlebar (`getBoundingClientRect().right/width`), telusuri rantai INDUK elemen terlebar sampai bertemu wadah yang lebarnya wajar, baru cari aturan CSS yang memaksa dan periksa prioritasnya (patch `max-width:100%` sering kalah dari aturan tema). Rumah masalah yang berulang di klien Astra: widget pihak ketiga di footer (Trustindex v14.2, 2130px pada layar 390px) melebarkan item grid karena `min-width: auto` — penutupnya `minmax(0, 1fr)`/`min-width: 0` + `max-width: 100%`. Laporkan angka sebelum–sesudah dan uji juga di desktop; widget yang tingginya 0 tetap bisa merusak lebar. Recipe + snippet pengukuran: `references/mobile-overflow-triage.md`.

## Pitfalls learned

Seluruh catatan jebakan (30+ butir, termasuk insiden TSLaw yang menghapus konten
halaman karena XML-RPC tanpa escaping) ada di `references/pitfalls.md`. **Baca berkas itu sebelum
menulis apa pun ke situs klien** — aturan utamanya: simpan salinan konten asli dulu, tulis konten
lewat REST (JSON), dan verifikasi dengan membaca ulang dari halaman live.
