Replacing the Google AJAX Search API (discontinued since 2014) with the Blogger JSON Feed API · Claude (Sonnet 5) · 2026-07-29
Friends, I tried to reuse the "Related Articles" widget I wrote back in 2010, and it doesn't run anymore. So I asked Anthropic's Claude Sonnet AI (the "weakest" of the trio — Sonnet, Opus, and Fable) — and it solved it in about 10 minutes!!! I'm passing along its solution here for you to see. I haven't written JavaScript in over 10 years, so there's no way I could have upgraded this myself without relearning it from scratch — but thanks to AI, it's done, and you're welcome to install it on your own blog if you like. Hoctro, 7/29/2026. Everything from here to the end of the post was written by Claude Sonnet!
1 · Why the old (2010) code no longer runs
The original script ("Related Articles - Take 5 - Using JQuery & Google Search API" by Hoctro, August 2010; see the original blog post) relies on three things that Google has since discontinued, or that modern browsers now block:
google.load('search', '1.0')andgoogle.search.WebSearch— this is the Google AJAX Search API, an embeddable web-search API that Google officially shut down in November 2014. Since that date, every call togoogle.search.*either errors out or silently does nothing — this is the main reason the widget "died" without any clear error message ever appearing on the page.http://www.google.com/jsapi— thejsapiloader for the "AJAX APIs" family (Search, the old Maps, Feeds, etc.) was discontinued in the same wave as the AJAX Search API.- Loading the script over
http://(nothttps://) — because Blogger has served every blog over HTTPS for years now, modern browsers block "mixed content" (an https page loading an http script) by default. Even if the Google AJAX Search API were still alive, that<script src="http://...">line would still be silently blocked in most browsers today.
In short: this isn't a syntax bug that can be patched — the entire API platform this code stood on has been removed. The only way to bring the widget back to life is to build it on a different data source that's still active.
2 · The replacement: use Blogger's own built-in JSON feed
(If your page is about the songwriter Lê Uyên Phương, the widget will go find related posts.)
Rather than hunting down a new "Google Search API" (Google now offers the Programmable Search Engine / Custom Search JSON API, but it requires creating a Search Engine ID and an API key in Google Cloud Console, and is only free for 100 calls/day before billing kicks in), this upgrade uses something Blogger already provides for free, with no limits and no extra sign-up: the blog's own JSON feed filtered by label.
Every Blogger blog has this endpoint:
https://YOUR-BLOG-NAME.blogspot.com/feeds/posts/default/-/LabelName?
alt=json-in-script&max-results=6&callback=yourFunctionName
This endpoint is not the dead Google AJAX Search API — it's Blogger's own native feed (in the old GData-style JSON format, with text fields wrapped in {"$t": "..."}), and Blogger has never removed it, because countless "Popular Posts" widgets and current Blogger themes still quietly rely on this exact mechanism. Advantages over the 2010 version:
- No API key needed, no sign-up, no quota — because this is your own blog's public feed, not a third-party search service.
alt=json-in-scriptloads via a<script>tag (the JSONP technique) — so it's completely unaffected by CORS, and works even on a custom domain mapped to Blogger.- No dependency on jQuery, no dependency on
ajax.googleapis.com(a library that is itself on Google's own gradual deprecation path) — plain vanilla JavaScript. - Same logic as the original: read the label(s) of the post currently being viewed, then find other posts in the same blog sharing that label — the only difference is that it queries Blogger's own post archive directly instead of going through Google's web search results.
3 · Full code — paste into Blogger
Go to Layout → Add a Gadget → HTML/JavaScript, and paste the entire block below. The widget is designed to sit right below the post content (on the single-post "item page" view); it checks itself and hides if you're on the home page or a label page (where there's no "current" post to find anything related to).
<!-- ===================================================================
Related Posts — 2026 upgrade
Replaces the discontinued (11/2014) Google AJAX Search API with
Blogger's own label-based JSON feed. No API key required.
Based on the original idea: Hoctro, "Related Articles - Take 5", 8/2010
https://hoctroviet.blogspot.com/2010/08/tien-ich-moi-viet-lien-quan-related.html
=================================================================== -->
<div id="hoctro-related-wrap">
<div id="hoctro-related-results">Loading related posts…</div>
</div>
<style>
#hoctro-related-wrap { margin: 24px 0; }
#hoctro-related-heading { font-size: 1.05em; font-weight: bold; margin-bottom: 8px; }
#hoctro-related-results { font-size: 0.95em; }
#hoctro-related-results ul { list-style: none; margin: 0; padding: 0; }
#hoctro-related-results li { padding: 4px 0; border-bottom: 1px dotted #ccc; }
#hoctro-related-results li:last-child { border-bottom: none; }
#hoctro-related-results a { text-decoration: none; }
#hoctro-related-results a:hover { text-decoration: underline; }
#hoctro-related-results .hr-thumb { display:inline-block; width:16px; height:16px;
vertical-align:middle; margin-right:6px; border-radius:2px; }
</style>
<script>
(function () {
'use strict';
var MAX_LABELS_TO_QUERY = 4; // max number of labels to query (limits the number of requests)
var MAX_RESULTS_SHOWN = 8; // max number of related posts to display
var callbackCounter = 0;
var pendingRequests = 0;
var collected = []; // {title, href, thumb}
var seenHrefs = {};
var currentHref = location.href.split('#')[0].split('?')[0];
function baseUrl() {
return location.protocol + '//' + location.host;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// The current post only has "related posts" on the item page (single-post view).
// Different Blogger themes set the "item-page" class or body data differently,
// so the safest approach is: only run once we find exactly one post block
// and at least one label link on the page.
function getCurrentLabels() {
var labels = [];
var seen = {};
// Different Blogger themes place label links in different spots/classes;
// probe them in order and use the first selector that matches.
var selectors = [
'.post-labels a',
'.post-footer-line .labels a',
'a[rel="tag"]',
'a[href*="/search/label/"]'
];
for (var s = 0; s < selectors.length; s++) {
var links = document.querySelectorAll(selectors[s]);
if (links.length) {
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute('href') || '';
var m = href.match(/\/search\/label\/([^?&#\/]+)/);
if (m) {
var label = decodeURIComponent(m[1].replace(/\+/g, ' '));
if (!seen[label]) { seen[label] = true; labels.push(label); }
} else if (!href) {
var text = (links[i].textContent || '').trim();
if (text && !seen[text]) { seen[text] = true; labels.push(text); }
}
}
break; // the first selector that returns results is enough — stop probing
}
}
return labels;
}
function renderResults() {
var box = document.getElementById('hoctro-related-results');
var heading = document.getElementById('hoctro-related-heading');
if (!collected.length) {
box.innerHTML = '<em>No related posts found.</em>';
return;
}
var html = '<ul>';
for (var i = 0; i < Math.min(collected.length, MAX_RESULTS_SHOWN); i++) {
var item = collected[i];
var thumb = item.thumb
? '<img class="hr-thumb" src="' + escapeHtml(item.thumb) + '" alt="">'
: '';
html += '<li>' + thumb + '<a href="' + escapeHtml(item.href) + '">' +
escapeHtml(item.title) + '</a></li>';
}
html += '</ul>';
box.innerHTML = html;
heading.style.display = '';
}
function handleFeed(feed) {
pendingRequests--;
var entries = (feed && feed.feed && feed.feed.entry) ? feed.feed.entry : [];
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
var href = '';
for (var l = 0; l < entry.link.length; l++) {
if (entry.link[l].rel === 'alternate') { href = entry.link[l].href; break; }
}
if (!href || href === currentHref || seenHrefs[href]) continue;
seenHrefs[href] = true;
collected.push({
title: entry.title && entry.title.$t ? entry.title.$t : '(untitled)',
href: href,
thumb: entry.media$thumbnail ? entry.media$thumbnail.url : ''
});
}
if (pendingRequests === 0) renderResults();
}
function queryLabel(label) {
pendingRequests++;
var cbName = 'hoctroRelatedCB_' + (callbackCounter++);
window[cbName] = function (feed) {
handleFeed(feed);
delete window[cbName];
};
var src = baseUrl() + '/feeds/posts/default/-/' + encodeURIComponent(label) +
'?alt=json-in-script&max-results=6&callback=' + cbName;
var script = document.createElement('script');
script.src = src;
script.async = true;
document.body.appendChild(script);
}
function init() {
var labels = getCurrentLabels();
if (!labels.length) {
document.getElementById('hoctro-related-results').innerHTML =
'<em>This post has no labels, so no related posts can be found.</em>';
return;
}
labels = labels.slice(0, MAX_LABELS_TO_QUERY);
for (var i = 0; i < labels.length; i++) queryLabel(labels[i]);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
4 · Installation and customization
- Go to Blogger → Theme → Edit HTML, or more simply: Layout → Add a Gadget → HTML/JavaScript, then paste the entire code block from section 3.
- If you use the "Add a Gadget" route, the widget will appear in whichever column position you chose, on every page (home page, label page, post page) — the script checks itself and shows a "no labels" message on pages that aren't a single post. If you want it to appear only below each individual post (matching how Hoctro's original was used), the more proper approach is to edit the HTML directly: find the
<data:post.body/>tag inside<b:includable id='post' var='post'>and paste the code block right after it. MAX_LABELS_TO_QUERYandMAX_RESULTS_SHOWNat the top of the script can be adjusted to taste (default: probe up to 4 labels, show up to 8 related posts).- If your theme doesn't expose post labels through any of the selectors in the
selectorslist (see the "Different Blogger themes place label links..." comment), open DevTools (F12) on a post, find the HTML block containing the label links at the end of the post, and add your theme's actual class/selector to that array. - The thumbnail image (
hr-thumb) only shows up if Blogger generated a thumbnail for that post (usually automatic if the post has an image) — nothing else needs to be configured.
5 · If you still want to use the Google Custom Search JSON API
The approach above is free, unlimited, and requires no sign-up. If you later want to go back to the "search the whole web, restricted to my own site" style of the 2010 original, that route still exists under a new name — Google Programmable Search Engine (formerly called Custom Search Engine), with the Custom Search JSON API standing in for the dead AJAX Search API. Things to know before choosing this route:
- You must create a Search Engine ID (
cx) at programmablesearchengine.google.com, scoping the search to your blog's domain. - You must create an API key in Google Cloud Console and enable the Custom Search API.
- Free for 100 calls/day; beyond that, billing applies per 1,000 calls.
- The endpoint is a normal REST JSON call (
https://www.googleapis.com/customsearch/v1?key=...&cx=...&q=...), made with plainfetch()— no JSONP needed.
Since a "related posts" widget only ever needs to search within your own blog (it doesn't need real web-search power), the Blogger-feed approach from sections 2–4 remains the more sensible choice for this purpose — and it's exactly the approach implemented in this upgrade.
***
RelatedPosts_Widget.md/.html — Build Process
How a dead 2010 Blogger widget got diagnosed and rebuilt · Claude (Sonnet 5) · 2026-07-29
1 · The task
The user had a "related posts" widget running on a Blogger site for about ten years that had stopped working, and asked for it to be upgraded using a current Google API. The only lead was a URL to the original 2010 source post: Hoctro's blog entry "Tiện ích mới: Viết liên quan (Related)" at hoctroviet.blogspot.com.
2 · Diagnosing why it broke
Rather than guess, the original post was fetched and the exact code it shipped was extracted verbatim (via WebFetch). The script did three things that no longer work on the modern web:
- Called
google.load('search', '1.0')and instantiatedgoogle.search.SearchControl/google.search.WebSearch— the Google AJAX Search API. Google shut this API down in November 2014. Every call intogoogle.search.*since then is a no-op or a silent failure, which explains why the widget just stopped rendering with no visible error. - Loaded its loader script from
http://www.google.com/jsapi— the API-loader endpoint tied to that same discontinued API family. - Loaded jQuery and the loader over plain
http://, which modern browsers block as mixed content on anhttps://-served Blogger blog, independent of the API being alive or not.
This wasn't a syntax bug or a selector that drifted — the entire platform the script stood on had been decommissioned. No patch to the existing code could fix it; it needed a different data source.
3 · Choosing the replacement API
Two candidate replacements exist for "search my own blog's posts, scoped by label, to find related articles":
- Google Programmable Search Engine / Custom Search JSON API — the direct successor to the dead AJAX Search API. Closest in spirit to the original ("search the web, restricted to my site"), but requires creating a Search Engine ID (
cx) and an API key in Google Cloud Console, and is capped at 100 free queries/day before billing. - Blogger's own JSON feed, filtered by label (
/feeds/posts/default/-/LabelName?alt=json-in-script&...) — a feed Blogger has never removed, requires no API key, no Cloud Console project, and no quota, because it's simply the blog's own content exposed as JSON, not a third-party search service.
Asked the user directly which to build (AskUserQuestion), along with a second question about what "4 files" should mean given this project's usual convention (essay-style Main+Process .md/.html pairs) is unusual for a code deliverable. The user chose the Blogger-feed approach and confirmed the Main+Process convention.
4 · Rebuilding the widget
The replacement keeps the original's actual behavior — read the current post's labels, then look up other posts sharing those labels — but re-implements the mechanics:
- No jQuery. The original used jQuery 1.3 from
ajax.googleapis.compurely for$(document).ready()and a.each()loop over label links; both are trivially vanilla JS (DOMContentLoaded, aforloop overquerySelectorAllresults). Dropping the dependency also removes exposure to Google Hosted Libraries' own deprecation trajectory. - Label scraping kept, made theme-agnostic. The original hard-coded one selector (
div.post-footer-line span.post-labels a) specific to Hoctro's 2010 theme. The rebuild tries a short list of selectors used by different Blogger theme generations (.post-labels a,.post-footer-line .labels a,a[rel="tag"],a[href*="/search/label/"]) and uses the first one that matches, since label markup varies by theme and the user's current theme is unknown. - JSONP against Blogger's own feed, one request per label (capped at 4 labels), each with its own dynamically-named global callback (
hoctroRelatedCB_N) so concurrent requests don't clobber a shared callback. Usingalt=json-in-script(script-tag JSONP) rather thanfetch()againstalt=jsonsidesteps any dependency on Blogger's feed CORS headers — a script tag load is immune to CORS by construction. - Client-side merge/dedupe across the per-label result sets, excluding the current post's own URL, capped at 8 displayed results — replicating what the original's single web-search call did in one round-trip, just assembled from several feed calls instead.
- Graceful empty states: if the current page has no label links at all (front page, label archive page, etc.), the widget reports that plainly instead of showing a spinner forever — the original had no such fallback and would hang silently in
Loading...off a dead API.
5 · Obstacles hit and the exact fix for each
Four separate problems surfaced while producing these files — one in the reused MD→HTML converter, one a Windows console quirk, one a second converter escaping gap, and one a real defect in the widget's own JS. Each is recorded here with the verbatim code change, not just a description, so a future session hitting the same symptom can find the fix without re-deriving it.
Obstacle 1 — convert_md_to_html.py had no support for fenced ` code blocks. The widget doc needed a large HTML/JS listing inside a fenced block; the existing converter only handled single-backtick inline code, so the whole listing was falling through to the "regular paragraph" branch and getting mangled. Fix — added fenced-block detection to the main parsing loop in convert_md_to_html.py, right after the horizontal-rule check:
# Fenced code block ```lang ... ```
if stripped.startswith('```'):
i += 1
code_lines = []
while i < len(lines) and lines[i].strip() != '```':
code_lines.append(lines[i])
i += 1
i += 1 # skip closing ```
html_parts.append('<pre><code>' + escape_html('\n'.join(code_lines)) + '</code></pre>')
continueand a matching <pre>/<pre> code/code block added to the CSS string constant (monospace, light-grey background, overflow-x: auto so long JS lines scroll instead of breaking the 900px page width).
Obstacle 2 — no escape_html() helper existed, so Obstacle 1's fix had nothing to call. Fix — added, right before process_inline:
def escape_html(text):
"""Escape HTML special characters for use inside <pre><code>."""
return text.replace('&', '&').replace('<', '<').replace('>', '>')
Obstacle 3 — inline single-backtick code spans were not escaping their content, a pre-existing gap in convert_md_to_html.py unrelated to the new fenced-block feature. The prose in RelatedPosts_Widget.md §2 contains `

No comments:
Post a Comment