Thay Google AJAX Search API (ngừng hoạt động từ 2014) bằng Blogger JSON Feed API · Claude (Sonnet 5) · 2026-07-29
Bạn, tôi thử dùng lại cái tiện ích "Related Articles" tôi viết năm 2010 thì không còn chạy nữa. Tôi bèn hỏi Anthropic Claude Sonnet AI (yếu nhứt trong bộ ba: Sonnet, Opus, và Fable) thì nó giài xong trong vòng 10 phút!!! Tôi gửi lại cho bạn xem lời giải của nó. Tôi đã không viết JavaScript từ hơn 10 năm nay, nên không thể nào upgrade được trừ khi học lại, nay nhờ AI mà thành, và bạn có thể lắp trên blog bạn nếu muốn. Hoctro 7/29/2026. Từ đây tới cuối bài là Claude Sonnet viết!
1 · Vì sao đoạn code cũ (2010) không còn chạy
Đoạn gốc ("Related Articles - Take 5 - Using JQuery & Google Search API" của Hoctro, tháng 8/2010, xem bài blog gốc) dựa vào ba thứ mà Google đã khai tử hoặc trình duyệt hiện đại đã chặn:
google.load('search', '1.0')vàgoogle.search.WebSearch— đây là Google AJAX Search API, một API tìm-kiếm-web nhúng được mà Google đã chính thức ngừng hoạt động (shut down) từ tháng 11/2014. Kể từ ngày đó, mọi lệnh gọigoogle.search.*chỉ trả về lỗi hoặc im lặng không làm gì — đây là nguyên nhân chính khiến widget "chết" mà không có thông báo lỗi rõ ràng nào trên trang.http://www.google.com/jsapi— bộ loaderjsapicho các API kiểu "AJAX APIs" (Search, Maps cũ, Feeds...) cũng bị khai tử cùng đợt với AJAX Search API.- Tải script qua
http://(không phảihttps://) — vì Blogger từ nhiều năm nay phục vụ mọi blog qua HTTPS, trình duyệt hiện đại chặn "mixed content" (trang https tải script http) theo mặc định. Ngay cả nếu Google AJAX Search API còn sống, dòng<script src="http://...">này vẫn sẽ bị chặn im lặng trên phần lớn trình duyệt ngày nay.
Nói gọn: đây không phải lỗi cú pháp có thể "vá" — cả nền tảng API mà đoạn code này đứng trên đó đã bị gỡ bỏ. Cách duy nhất để widget sống lại là xây trên một nguồn dữ liệu khác vẫn còn hoạt động.
2 · Hướng thay thế: dùng feed JSON có sẵn của chính Blogger
Thay vì tìm lại một "Google Search API" mới (Google hiện có Programmable Search Engine / Custom Search JSON API, nhưng đòi hỏi tạo Search Engine ID, API key trong Google Cloud Console, và chỉ miễn phí 100 lượt gọi/ngày trước khi tính phí), bản nâng cấp này dùng thứ Blogger đã tự cung cấp miễn phí, không giới hạn, không cần đăng ký gì thêm: feed JSON theo nhãn (label) của chính blog.
Mọi blog Blogger đều có endpoint:
https://TEN-BLOG.blogspot.com/feeds/posts/default/-/TenNhan?alt=json-in-script&
max-results=6&callback=tenHam
Endpoint này không phải Google AJAX Search API đã chết — nó là feed nguyên bản của chính Blogger (định dạng JSON kiểu GData cũ, các trường văn bản bọc trong {"$t": "..."}), và Blogger chưa từng gỡ bỏ nó vì hàng loạt widget "Bài xem nhiều" (Popular Posts) và các theme Blogger hiện tại vẫn âm thầm dùng đúng cơ chế này. Ưu điểm so với bản 2010:
- Không cần API key, không cần đăng ký, không có quota — vì đây là feed public của chính blog bạn, không phải một dịch vụ tìm kiếm bên thứ ba.
alt=json-in-scriptnạp qua thẻ<script>(kỹ thuật JSONP) — nên hoàn toàn không bị chặn bởi CORS, dùng được kể cả trên domain riêng gắn với Blogger.- Không phụ thuộc jQuery, không phụ thuộc
ajax.googleapis.com(thư viện này cũng đang trên lộ trình bị Google khai tử dần) — code JavaScript thuần (vanilla JS). - Logic tương tự bản gốc: đọc nhãn (label) của bài đang xem, rồi tìm các bài khác trong cùng blog có cùng nhãn — chỉ khác là tra thẳng vào kho bài viết của Blogger thay vì đi vòng qua kết quả tìm kiếm web của Google.
3 · Code đầy đủ — dán vào Blogger
Vào Bố cục (Layout) → Thêm tiện ích (Add a Gadget) → HTML/JavaScript, dán nguyên khối dưới đây. Widget được thiết kế để đặt ngay dưới nội dung bài viết (trang xem từng bài — "item page"); nó tự kiểm tra và ẩn mình nếu đang ở trang chủ hoặc trang nhãn (nơi không có một bài viết "hiện tại" để tìm liên quan).
<!-- ===================================================================
Bài Viết Liên Quan — bản nâng cấp 2026
Thay Google AJAX Search API (đã ngừng hoạt động 11/2014) bằng
feed JSON theo nhãn của chính Blogger. Không cần API key.
Dựa trên ý tưởng gốc: 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">Đang tải bài viết liên quan…</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; // số nhãn tối đa sẽ dò feed (giới hạn số request)
var MAX_RESULTS_SHOWN = 8; // số bài liên quan tối đa hiển thị
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, '"');
}
// Bài đang xem chỉ có "liên quan" trên trang từng bài (item page).
// Blogger đặt class "item-page" hoặc <body> có data khác nhau tùy theme,
// nên cách chắc ăn nhất là: chỉ chạy nếu tìm thấy đúng 1 khối bài viết
// và có ít nhất 1 link nhãn trên trang.
function getCurrentLabels() {
var labels = [];
var seen = {};
// Các theme Blogger khác nhau đặt link nhãn ở những nơi/class khác nhau;
// dò theo thứ tự, dùng bộ chọn đầu tiên khớp được.
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; // bộ chọn đầu tiên có kết quả là đủ, không dò tiếp
}
}
return labels;
}
function renderResults() {
var box = document.getElementById('hoctro-related-results');
var heading = document.getElementById('hoctro-related-heading');
if (!collected.length) {
box.innerHTML = '<em>Không tìm thấy bài viết liên quan.</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 : '(không có tiêu đề)',
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>Bài này chưa gắn nhãn nên không thể tìm bài liên quan.</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 · Cách cài đặt và tùy chỉnh
- Vào Blogger → Chủ đề (Theme) → Chỉnh sửa HTML, hoặc đơn giản hơn: Bố cục (Layout) → Thêm tiện ích → HTML/JavaScript, rồi dán nguyên khối code ở mục 3.
- Nếu dùng đường "Thêm tiện ích", widget sẽ xuất hiện ở vị trí cột bạn chọn trên mọi trang (trang chủ, trang nhãn, trang bài viết) — script tự kiểm tra và hiện thông báo "chưa gắn nhãn" ở những trang không có bài viết đơn lẻ. Nếu muốn nó chỉ hiện dưới mỗi bài viết (giống cách dùng nguyên bản của Hoctro), cách chuẩn hơn là chỉnh trực tiếp trong Chỉnh sửa HTML: tìm thẻ
<data:post.body/>bên trong<b:includable id='post' var='post'>và dán khối code ngay sau nó. MAX_LABELS_TO_QUERYvàMAX_RESULTS_SHOWNở đầu script có thể chỉnh theo ý (mặc định: dò tối đa 4 nhãn, hiện tối đa 8 bài liên quan).- Nếu theme của bạn không hiển thị nhãn bài viết bằng bất kỳ selector nào trong danh sách
selectors(mục "Các theme Blogger khác nhau đặt link nhãn..."), mở DevTools (F12) trên một bài viết, tìm khối HTML chứa các link nhãn ở cuối bài, và thêm class/selector đúng của theme bạn vào mảng đó. - Ảnh đại diện (
hr-thumb) chỉ hiện nếu Blogger có tạo thumbnail cho bài đó (thường tự động nếu bài có ảnh) — không có gì cần cấu hình thêm.
5 · Nếu vẫn muốn dùng Google Custom Search JSON API
Cách trên là lựa chọn miễn phí, không giới hạn, không cần đăng ký. Nếu sau này muốn quay lại kiểu "tìm trên toàn web, giới hạn trong site của mình" giống bản gốc 2010, hướng đó vẫn tồn tại dưới tên gọi mới — Google Programmable Search Engine (trước đây gọi là Custom Search Engine), với Custom Search JSON API thay cho AJAX Search API đã chết. Khác biệt cần biết trước khi chọn hướng này:
- Phải tạo một Search Engine ID (
cx) tại programmablesearchengine.google.com, giới hạn phạm vi tìm kiếm vào tên miền blog. - Phải tạo API key trong Google Cloud Console và bật Custom Search API.
- Miễn phí 100 lượt gọi/ngày; vượt quá sẽ tính phí theo mỗi 1.000 lượt gọi.
- Endpoint gọi là REST JSON bình thường (
https://www.googleapis.com/customsearch/v1?key=...&cx=...&q=...), gọi bằngfetch()thuần, không cần JSONP.
Vì widget "bài liên quan" chỉ cần tìm trong chính blog của mình (không cần sức mạnh tìm-kiếm-web thật sự), phương án feed-của-Blogger ở mục 2–4 vẫn là lựa chọn hợp lý hơn cho việc này — nó chính là cách được implement trong bản nâng cấp này.
***
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 `
