{"id":468,"date":"2026-06-02T11:11:43","date_gmt":"2026-06-02T11:11:43","guid":{"rendered":"https:\/\/saveyourclicks.com\/blog\/build-diaries\/internal-linking-strategy-from-search-console-data\/"},"modified":"2026-06-02T11:11:43","modified_gmt":"2026-06-02T11:11:43","slug":"internal-linking-strategy-from-search-console-data","status":"publish","type":"post","link":"https:\/\/saveyourclicks.com\/blog\/en\/build-diaries\/internal-linking-strategy-from-search-console-data\/","title":{"rendered":"An Internal Linking Strategy Built From Search Console Data"},"content":{"rendered":"\n<link rel=\"preconnect\" href=\"https:\/\/fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https:\/\/fonts.gstatic.com\" crossorigin>\n<link rel=\"stylesheet\" media=\"print\" onload=\"this.media='all';this.onload=null\" href=\"https:\/\/fonts.googleapis.com\/css2?family=Inter:wght@400;600;700;800&#038;display=swap\">\n\n<noscript><link rel=\"stylesheet\" href=\"https:\/\/fonts.googleapis.com\/css2?family=Inter:wght@400;600;700;800&#038;display=swap\"><\/noscript>\n\n<style>html{scroll-behavior:smooth;scroll-padding-top:32px}@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}.bd-article{--bg:#ffffff;--bg-alt:#f8fafc;--bg-dark:#0a0f1e;--primary:#3b82f6;--primary-dark:#2563eb;--accent:#8b5cf6;--accent-soft:#faf5ff;--gradient:linear-gradient(135deg,#3b82f6,#8b5cf6);--text:#0f172a;--text-2:#475569;--text-muted:#94a3b8;--text-light:#f1f5f9;--border:#e2e8f0;--border-strong:#cbd5e1;--good:#16a34a;--good-bg:#dcfce7;--warn:#dc2626;--warn-bg:#fee2e2;--warn-soft:#fef2f2;--code-bg:#f1f5f9;--radius:12px;--radius-lg:16px;--shadow-sm:0 1px 2px rgba(15,23,42,.04);--shadow:0 4px 12px rgba(15,23,42,.06);--shadow-lift:0 12px 32px rgba(15,23,42,.08);--ease:cubic-bezier(.22,1,.36,1);font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;color:var(--text);background:var(--bg);line-height:1.65;-webkit-font-smoothing:antialiased;font-weight:400}.bd-article *,.bd-article *::before,.bd-article *::after{box-sizing:border-box}.bd-article p{margin:0 0 1em}.bd-article p:last-child{margin-bottom:0}.bd-article a{color:var(--primary-dark);text-decoration:none;transition:color .15s var(--ease)}.bd-article a:hover{color:var(--primary)}.bd-article code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em;background:var(--code-bg);color:var(--text);padding:.1em .4em;border-radius:4px;border:1px solid var(--border)}.bd-article em{font-style:normal;color:var(--primary-dark);font-weight:600}.bd-container{max-width:840px;margin:0 auto;padding:0 24px}.bd-article .bd-hero{background:var(--bg-dark);color:var(--text-light);padding:88px 0 72px;position:relative;overflow:hidden}.bd-article .bd-hero::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70% 50% at 50% -10%,rgba(59,130,246,.18),transparent 60%),radial-gradient(ellipse 50% 40% at 90% 90%,rgba(139,92,246,.14),transparent 60%);pointer-events:none}.bd-article .bd-hero > .bd-container{position:relative;z-index:1}.bd-article .bd-eyebrow{display:inline-flex;align-items:center;gap:8px;padding:6px 14px;margin-bottom:28px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.25);border-radius:50px;font-size:13px;font-weight:600;color:#93c5fd}.bd-article .bd-eyebrow time{color:var(--text-muted);font-weight:400}.bd-article .bd-dot{width:6px;height:6px;border-radius:50%;background:#22c55e;animation:bd-pulse 2s ease-in-out infinite}@keyframes bd-pulse{0%,100%{opacity:1}50%{opacity:.4}}.bd-article .bd-hero h1{font-size:clamp(32px,5.5vw,52px);font-weight:800;letter-spacing:-.025em;line-height:1.12;color:var(--text-light);margin:0 0 20px}.bd-article .bd-grad{background:var(--gradient);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}.bd-article .bd-lead{font-size:18px;color:var(--text-muted);line-height:1.7;max-width:680px;margin:0 0 40px}.bd-article .bd-honesty{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;padding-top:32px;border-top:1px solid rgba(255,255,255,.08);margin:0}.bd-article .bd-honesty > div{display:flex;flex-direction:column-reverse;gap:4px;min-width:0}.bd-article .bd-honesty dt{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin:0;font-weight:600}.bd-article .bd-honesty dd{font-size:24px;font-weight:800;color:var(--text-light);margin:0;letter-spacing:-.02em;line-height:1.1}.bd-article .bd-timeline{padding:72px 0 32px;background:var(--bg)}<\/style>\n\n<link rel=\"stylesheet\" media=\"print\" onload=\"this.media='all';this.onload=null\" href=\"\/blog\/wp-content\/uploads\/build-diaries\/internal-linking-strategy-from-search-console-data.css?v=b8bb7505\">\n<noscript><link rel=\"stylesheet\" href=\"\/blog\/wp-content\/uploads\/build-diaries\/internal-linking-strategy-from-search-console-data.css?v=b8bb7505\"><\/noscript>\n\n<script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"Article\",\n  \"headline\": \"An Internal Linking Strategy Built From Search Console Data\",\n  \"description\": \"A timestamped build diary about turning a Google Search Console export into an internal linking strategy: scoring 102 blog posts by topical relevance, then by organic traffic, to fill the related-posts box on 7 landing pages.\",\n  \"author\": { \"@type\": \"Person\", \"name\": \"Yusof Ansari\", \"url\": \"https:\/\/saveyourclicks.com\/\" },\n  \"publisher\": { \"@type\": \"Organization\", \"name\": \"Save Your Clicks\", \"url\": \"https:\/\/saveyourclicks.com\/\", \"logo\": { \"@type\": \"ImageObject\", \"url\": \"https:\/\/saveyourclicks.com\/assets\/logo.png\" } },\n  \"datePublished\": \"2026-06-02\",\n  \"dateModified\": \"2026-06-02\",\n  \"articleSection\": \"Build Diaries\",\n  \"image\": \"https:\/\/saveyourclicks.com\/blog\/wp-content\/uploads\/2026\/06\/build-diary-internal-linking-strategy-from-search-console-data-2.png\",\n  \"keywords\": \"internal linking strategy, internal links seo, internal linking best practices, content recommendation engine, related posts, google search console, build diary\"\n}\n<\/script>\n<script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    { \"@type\":\"Question\",\"name\":\"What is the fastest way to pick internal links for a landing page?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Score every candidate post by topical relevance to the landing page first, then order within each relevance tier by organic performance. Relevance decides who qualifies; traffic decides the order. Pure traffic sorting buries the on-topic posts; pure relevance sorting surfaces zero-traffic pages first.\"}},\n    { \"@type\":\"Question\",\"name\":\"Why did the Google Sheet CSV export return zero bytes?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"The sheet's General access was set to Restricted. The public export endpoints (export?format=csv and gviz\/tq) return HTTP 200 with an empty body when the document isn't link-shared. Set General access to Anyone with the link, Viewer, and both endpoints return the full CSV.\"}},\n    { \"@type\":\"Question\",\"name\":\"How do you get a blog post's cover image and summary without scraping the whole article?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Request each post once and read its OpenGraph tags. og:image is the cover image and og:description is a clean human-written summary. One request per URL, run in parallel, returns image plus summary in seconds without parsing article HTML.\"}},\n    { \"@type\":\"Question\",\"name\":\"Why exclude category and tag pages from the candidate set?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Listing pages (category, tag, the blog index, author archives) appear in a Search Console export alongside real posts, but they have no cover image and are not link-worthy destinations. Filtering by URL pattern and requiring an og:image removes them automatically.\"}},\n    { \"@type\":\"Question\",\"name\":\"Do WebP images work in every CMS editor?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"No. Some editors reject WebP on upload or fetch. If a post's only cover image is WebP and no JPG or PNG sibling exists on the server, you must convert and re-host it, or swap the post for the next non-WebP candidate. Check image formats before shipping a related-posts feed.\"}}\n  ]\n}\n<\/script>\n\n<article class=\"bd-article\">\n\n  <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 HERO \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n  <header class=\"bd-hero\">\n    <div class=\"bd-container\">\n      <p class=\"bd-eyebrow\"><span class=\"bd-dot\" aria-hidden=\"true\"><\/span>Build Diary \u00b7 06 \u00b7 <time datetime=\"2026-06-02\">June 2, 2026<\/time><\/p>\n      <p class=\"bd-hero-title\">An <span class=\"bd-grad\">internal linking strategy<\/span> built from Search Console data \u2014 every minute timestamped.<\/p>\n      <p class=\"bd-lead\">Seven landing pages, each with an empty &#8220;related blog posts&#8221; box at the bottom. One Search Console export with 119 rows. The job: rank every post by how well it fits each page, then by how much organic traffic it already pulls \u2014 and hand back a ready-to-ship feed. Here&#8217;s the exact build log, dead ends included.<\/p>\n\n      <dl class=\"bd-honesty\" aria-label=\"Build summary\">\n        <div><dt>Build time<\/dt><dd><span class=\"bd-num\">~25m<\/span><\/dd><\/div>\n        <div><dt>Bugs fixed<\/dt><dd><span class=\"bd-num\">2<\/span><\/dd><\/div>\n        <div><dt>Breakthroughs<\/dt><dd><span class=\"bd-num\">2<\/span><\/dd><\/div>\n        <div><dt>Dead ends<\/dt><dd><span class=\"bd-num\">1<\/span><\/dd><\/div>\n      <\/dl>\n    <\/div>\n  <\/header>\n\n  <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 TIMELINE \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n  <main class=\"bd-timeline\">\n    <div class=\"bd-container\">\n\n      <ol class=\"bd-rail\" aria-label=\"Build timeline\">\n\n        <!-- \u2500\u2500 Milestone: start \u2500\u2500 -->\n        <li class=\"bd-event bd-milestone\" id=\"start\">\n          <time class=\"bd-event-time\" datetime=\"PT0M\">+0:00<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\">Start<\/p>\n            <h2>The plan: fill seven &#8220;related posts&#8221; boxes from real search data.<\/h2>\n            <p>A client site has seven landing pages \u2014 home, virtual staging, photo editing, day-to-dusk, item removal, image enhancement, interior design \u2014 each ending in a box that&#8217;s supposed to recommend blog posts. Right now they&#8217;re empty. I have a Search Console export of every blog URL with clicks and impressions. The goal isn&#8217;t &#8220;newest posts&#8221; or &#8220;most popular posts.&#8221; It&#8217;s the right posts: topically close to the page, ordered by the traffic they already earn. Twenty suggestions per page, one tab each, each row carrying a cover image, a summary, and alt text so the box can be populated directly.<\/p>\n            <figure class=\"bd-figure\">\n              <img decoding=\"async\" src=\"\/blog\/wp-content\/uploads\/2026\/06\/build-diary-internal-linking-strategy-from-search-console-data-1.png\" alt=\"Google Search Console export of blog URLs with clicks, impressions, CTR and position columns\" loading=\"lazy\">\n              <figcaption>The raw material: one row per blog URL, with clicks, impressions, CTR and average position. Everything downstream is a transform of this.<\/figcaption>\n            <\/figure>\n          <\/div>\n        <\/li>\n\n        <!-- \u2500\u2500 Bug 01 \u2500\u2500 -->\n        <li class=\"bd-event bd-bug\" id=\"bug-01\">\n          <time class=\"bd-event-time\" datetime=\"PT2M\">+0:02<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\"><span class=\"bd-pill bd-pill-warn\">Bug 01<\/span><span class=\"bd-cost\">\u23f1 8 min \u00b7 \ud83e\udde0 reusable lesson<\/span><\/p>\n            <h2>The zero-byte export.<\/h2>\n            <dl class=\"bd-bug-detail\">\n              <div><dt>Symptom<\/dt><dd>Pulling the sheet as CSV returned nothing. <code>export?format=csv<\/code> answered <code>HTTP 200<\/code> \u2014 but with a <code>0<\/code>-byte body. The <code>gviz\/tq<\/code> endpoint did the same. The <code>htmlview<\/code> page loaded 40&nbsp;KB of shell with not a single data row in it.<\/dd><\/div>\n              <div><dt>Root cause<\/dt><dd>The document&#8217;s <em>General access<\/em> was set to <em>Restricted<\/em>. Google&#8217;s public export endpoints don&#8217;t error on a private file \u2014 they return an empty <code>200<\/code>, which looks exactly like an empty sheet. The status code lies; only the byte count tells the truth.<\/dd><\/div>\n              <div><dt>Fix<\/dt><dd>Set General access to <em>Anyone with the link \u2192 Viewer<\/em> and re-share. Both endpoints immediately returned the full CSV \u2014 119 rows.<\/dd><\/div>\n            <\/dl>\n          <\/div>\n        <\/li>\n\n        <!-- \u2500\u2500 Eureka 01 \u2500\u2500 -->\n        <li class=\"bd-event bd-eureka\" id=\"eureka-01\">\n          <time class=\"bd-event-time\" datetime=\"PT9M\">+0:09<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\"><span class=\"bd-pill bd-pill-eureka\">Eureka \u2726<\/span><\/p>\n            <h2>The cover image and the summary are already in the page \u2014 as OpenGraph tags.<\/h2>\n            <p class=\"bd-eureka-shift\">The shift: stop thinking &#8220;scrape 119 articles,&#8221; start thinking &#8220;read 119 OpenGraph headers.&#8221;<\/p>\n            <p>Each row needed a cover image, a short summary, and alt text. My first instinct was to fetch and parse every article \u2014 slow, brittle, 119 times over. Then it clicked: every post already publishes its cover as <code>og:image<\/code> and a hand-written summary as <code>og:description<\/code>. One request per URL, just the <code>&lt;head&gt;<\/code>, grep two meta tags. Run twelve in parallel and the whole catalog resolves in seconds. Alt text I generate from the cleaned title \u2014 more consistent than the half-empty alts already on the images.<\/p>\n          <\/div>\n        <\/li>\n\n        <!-- \u2500\u2500 Bug 02 \u2500\u2500 -->\n        <li class=\"bd-event bd-bug\" id=\"bug-02\">\n          <time class=\"bd-event-time\" datetime=\"PT11M\">+0:11<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\"><span class=\"bd-pill bd-pill-warn\">Bug 02<\/span><span class=\"bd-cost\">\u23f1 3 min \u00b7 \ud83e\udde0 reusable lesson<\/span><\/p>\n            <h2>Ten &#8220;posts&#8221; with no cover image weren&#8217;t posts at all.<\/h2>\n            <dl class=\"bd-bug-detail\">\n              <div><dt>Symptom<\/dt><dd>Of 120 URLs, 10 came back with an empty <code>og:image<\/code>. At first that looked like missing cover images to backfill.<\/dd><\/div>\n              <div><dt>Root cause<\/dt><dd>Those 10 were listing pages, not articles: <code>\/category\/\u2026<\/code>, <code>\/tag\/\u2026<\/code>, the <code>\/blog\/<\/code> index, an author archive, plus the site&#8217;s &#8220;most-popular&#8221; and &#8220;latest&#8221; rollups. A Search Console export lists every indexed URL, not just posts. None of them is a link-worthy destination for a related-posts box.<\/dd><\/div>\n              <div><dt>Fix<\/dt><dd>Filter by URL pattern <em>and<\/em> require an <code>og:image<\/code>. The two rules together drop every listing page and leave 102 genuine posts \u2014 the real candidate pool.<\/dd><\/div>\n            <\/dl>\n          <\/div>\n        <\/li>\n\n        <!-- \u2500\u2500 Eureka 02 \u2500\u2500 -->\n        <li class=\"bd-event bd-eureka\" id=\"eureka-02\">\n          <time class=\"bd-event-time\" datetime=\"PT14M\">+0:14<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\"><span class=\"bd-pill bd-pill-eureka\">Eureka \u2726<\/span><\/p>\n            <h2>Relevance is a gate; traffic is the sort order.<\/h2>\n            <p class=\"bd-eureka-shift\">The shift: stop trying to rank by one number. Bucket by relevance first, then sort by traffic inside each bucket.<\/p>\n            <p>Sorting the candidates by clicks alone put &#8220;Best Real Estate Companies in the U.S.&#8221; at the top of every page \u2014 high traffic, wrong topic. Sorting by relevance alone floated brand-new zero-traffic posts above proven ones. Neither is a strategy. The fix is two-stage: score each post&#8217;s <em>topical fit<\/em> to the landing page into tiers (an exact &#8220;virtual staging&#8221; match outranks a generic &#8220;real estate photo&#8221; match), then order <em>within each tier<\/em> by clicks, then impressions. Relevance decides who qualifies for the box; organic performance decides the order they appear. A nice confirmation it was keying on the right signal: a post titled &#8220;AI Virtual Staging: LED Lighting Secrets&#8221; scored as a top virtual-staging match purely from its own title \u2014 exactly right.<\/p>\n          <\/div>\n        <\/li>\n\n        <!-- \u2500\u2500 Dead end \u2500\u2500 -->\n        <li class=\"bd-event bd-deadend\" id=\"deadend-01\">\n          <time class=\"bd-event-time\" datetime=\"PT20M\">+0:20<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\"><span class=\"bd-pill bd-pill-muted\">Dead end<\/span><span class=\"bd-cost\">\u23f1 4 min<\/span><\/p>\n            <h2>Trying to swap the WebP covers for a JPG or PNG on the same server.<\/h2>\n            <p>The editor that ingests this feed doesn&#8217;t accept WebP images, and nine of the chosen posts have WebP covers. I assumed WordPress had kept a JPG or PNG original next to each one, so I probed the obvious sibling paths. Nothing. Then I checked inside the posts for any non-WebP image to borrow \u2014 the only one I found was the site logo. These nine posts are WebP top to bottom, with no server-side fallback to point at.<\/p>\n            <p class=\"bd-deadend-lesson\"><strong>What I learned:<\/strong> validate the <em>format<\/em> constraints of the destination before you rank by relevance and traffic. A perfect pick that the target system can&#8217;t render is not a pick \u2014 it&#8217;s a conversion task or a swap, and it&#8217;s cheaper to discover that at selection time than after handoff.<\/p>\n          <\/div>\n        <\/li>\n\n        <!-- \u2500\u2500 Milestone: shipped \u2500\u2500 -->\n        <li class=\"bd-event bd-milestone bd-shipped\" id=\"shipped\">\n          <time class=\"bd-event-time\" datetime=\"PT25M\">+0:25<\/time>\n          <div class=\"bd-event-card\">\n            <p class=\"bd-event-tag\">Shipped<\/p>\n            <h2>Seven tabs, 20 picks each, ready to populate.<\/h2>\n            <p>The output is a single workbook with one tab per landing page. Each row carries rank, title, summary, cover image URL, alt text, destination URL, clicks and impressions \u2014 every field the box needs. The home tab ignores topic and lists the highest-traffic posts outright, which is what a homepage wants. Every image URL was checked for a live <code>200<\/code> before shipping. The WebP nine are flagged, not hidden \u2014 an honest feed beats a silently broken one.<\/p>\n            <figure class=\"bd-figure\">\n              <img decoding=\"async\" src=\"\/blog\/wp-content\/uploads\/2026\/06\/build-diary-internal-linking-strategy-from-search-console-data-2.png\" alt=\"Finished workbook with seven landing-page tabs, each row showing title, summary, image URL, alt text and traffic columns\" loading=\"lazy\">\n              <figcaption>The shipped feed: one tab per landing, each post carrying its title, summary, cover image, alt text and Search Console numbers \u2014 ready to drop into the related-posts box.<\/figcaption>\n            <\/figure>\n          <\/div>\n        <\/li>\n\n      <\/ol>\n    <\/div>\n  <\/main>\n\n  <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 TIME LEDGER \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n  <section class=\"bd-ledger-section\" id=\"ledger\">\n    <div class=\"bd-container\">\n      <header class=\"bd-section-header\">\n        <p class=\"bd-section-num\">The honest accounting<\/p>\n        <h2>Where the 25 minutes <em>actually<\/em> went.<\/h2>\n        <p class=\"bd-section-lead\">No model training, no clever embeddings. Most of the time was access and data hygiene; the ranking logic itself was small once the data was clean.<\/p>\n      <\/header>\n\n      <dl class=\"bd-ledger\" aria-label=\"Time spent by phase\">\n        <div class=\"bd-ledger-row\" style=\"--w:32%\">\n          <dt>Setup &amp; access<\/dt>\n          <dd><span class=\"bd-ledger-bar bd-ledger-setup\"><\/span><span class=\"bd-ledger-num\">8m<\/span><\/dd>\n        <\/div>\n        <div class=\"bd-ledger-row\" style=\"--w:24%\">\n          <dt>Debug bugs<\/dt>\n          <dd><span class=\"bd-ledger-bar bd-ledger-bug\"><\/span><span class=\"bd-ledger-num\">6m<\/span><\/dd>\n        <\/div>\n        <div class=\"bd-ledger-row\" style=\"--w:16%\">\n          <dt>Dead ends<\/dt>\n          <dd><span class=\"bd-ledger-bar bd-ledger-deadend\"><\/span><span class=\"bd-ledger-num\">4m<\/span><\/dd>\n        <\/div>\n        <div class=\"bd-ledger-row\" style=\"--w:12%\">\n          <dt>Eureka moments<\/dt>\n          <dd><span class=\"bd-ledger-bar bd-ledger-eureka\"><\/span><span class=\"bd-ledger-num\">3m (worth it)<\/span><\/dd>\n        <\/div>\n        <div class=\"bd-ledger-row\" style=\"--w:16%\">\n          <dt>Polish &amp; ship<\/dt>\n          <dd><span class=\"bd-ledger-bar bd-ledger-polish\"><\/span><span class=\"bd-ledger-num\">4m<\/span><\/dd>\n        <\/div>\n      <\/dl>\n      <p class=\"bd-ledger-takeaway\"><strong>Takeaway:<\/strong> the data was the project \u2014 a private share setting and ten listing pages cost more than the ranking logic ever did; checking access and image formats <em>first<\/em> would have shortcut the whole thing.<\/p>\n    <\/div>\n  <\/section>\n\n  <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 FAQ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n  <section class=\"bd-faq-section\" id=\"faq\">\n    <div class=\"bd-container\">\n      <header class=\"bd-section-header\">\n        <p class=\"bd-section-num\">Questions you might have<\/p>\n        <h2>The five <em>real<\/em> questions about this.<\/h2>\n      <\/header>\n\n      <div class=\"bd-faq\">\n        <details>\n          <summary>What&#8217;s the fastest way to pick internal links for a landing page?<\/summary>\n          <p>Score every candidate post by topical relevance to the landing page first, then order within each relevance tier by organic performance. Relevance decides who qualifies; traffic decides the order. Pure traffic sorting buries the on-topic posts under your popular-but-unrelated ones; pure relevance sorting surfaces zero-traffic pages first. The two-stage version is the whole internal linking strategy.<\/p>\n        <\/details>\n        <details>\n          <summary>Why did the Google Sheet CSV export return zero bytes?<\/summary>\n          <p>The sheet&#8217;s General access was Restricted. The public export endpoints \u2014 <code>export?format=csv<\/code> and <code>gviz\/tq<\/code> \u2014 return <code>HTTP 200<\/code> with an empty body when the document isn&#8217;t link-shared, which is indistinguishable from an empty sheet. Set General access to <em>Anyone with the link, Viewer<\/em> and both endpoints return the full CSV.<\/p>\n        <\/details>\n        <details>\n          <summary>How do you get a post&#8217;s cover image and summary without scraping it?<\/summary>\n          <p>Request each post once and read its OpenGraph tags. <code>og:image<\/code> is the cover image and <code>og:description<\/code> is a clean, human-written summary. One request per URL, run in parallel, returns image plus summary in seconds \u2014 no article parsing, no headless browser.<\/p>\n        <\/details>\n        <details>\n          <summary>Why exclude category and tag pages from the candidates?<\/summary>\n          <p>A Search Console export lists every indexed URL, so listing pages \u2014 categories, tags, the blog index, author archives, &#8220;popular&#8221; rollups \u2014 show up next to real posts. They have no cover image and aren&#8217;t link-worthy destinations. Filtering by URL pattern and requiring an <code>og:image<\/code> removes them in one pass.<\/p>\n        <\/details>\n        <details>\n          <summary>Do WebP images work in every CMS editor?<\/summary>\n          <p>No. Some editors reject WebP on upload or fetch. If a post&#8217;s only cover is WebP and there&#8217;s no JPG or PNG sibling on the server, you either convert and re-host it or swap the post for the next non-WebP candidate. Check image formats during selection, not after handoff.<\/p>\n        <\/details>\n      <\/div>\n    <\/div>\n  <\/section>\n\n  <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 FOOTER \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n  <footer class=\"bd-footer\">\n    <div class=\"bd-container\">\n      <p>Build Diaries \u00b7 A series on shipping AI-built work to production \u2014 every minute timestamped, every dead end shown, every breakthrough named.<\/p>\n      <p class=\"bd-footer-meta\"><time datetime=\"2026-06-02\">June 2, 2026<\/time> \u00b7 by <a href=\"https:\/\/saveyourclicks.com\/\" rel=\"author\">Yusof Ansari<\/a><\/p>\n    <\/div>\n  <\/footer>\n\n<\/article>\n\n<script src=\"\/blog\/wp-content\/uploads\/build-diaries\/internal-linking-strategy-from-search-console-data.js?v=33ca7bfc\" defer><\/script>\n\n","protected":false},"excerpt":{"rendered":"<p>A timestamped build diary: I turned a Search Console export into an internal linking strategy for 7 landing pages, scoring 102 blog posts by relevance, then organic traffic.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[103],"tags":[],"class_list":["post-468","post","type-post","status-publish","format-standard","hentry","category-build-diaries"],"lang":"en","translations":{"en":468},"pll_sync_post":[],"_links":{"self":[{"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/posts\/468","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/comments?post=468"}],"version-history":[{"count":0,"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/posts\/468\/revisions"}],"wp:attachment":[{"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/media?parent=468"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/categories?post=468"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/saveyourclicks.com\/blog\/wp-json\/wp\/v2\/tags?post=468"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}