Chuyển đến nội dung chính
  1. Bài viết/

Cloudflare trả về cho Googlebot một trang trống

· loading · loading ·
Nhân Tài Đức
Tác giả
Nhân Tài Đức
Người dẫn dắt đội ngũ và kỹ sư phần mềm, sống tại Seoul, Hàn Quốc

Marketplace của tôi là một ứng dụng React Native Web, export bằng Expo. Trước khi JavaScript chạy, trang listing nào cũng chỉ có đúng một thẻ script và một thẻ div rỗng. Với người dùng thì chẳng sao, nhưng với công cụ tìm kiếm thì coi như xong: Google thỉnh thoảng chạy được JS, Bing thì không, còn crawler của Naver — thứ quan trọng nhất với một trang web dành cho cộng đồng người Hàn ở New Zealand — thì chắc chắn là không.

Bài này kể lại quá trình tôi làm cho các trang đó hiện ra với công cụ tìm kiếm, rồi chuyện một cache rule phá hỏng tất cả theo cách tôi không hề lường trước.

Dynamic rendering, chứ không phải cloaking
#

Cách sửa thì chẳng có gì mới, và được chính Google chấp nhận: nhận diện crawler, rồi trả cho nó HTML đã render sẵn ở server, chứa đúng nội dung mà SPA lẽ ra sẽ vẽ lên màn hình. Không phải nội dung khác. Vẫn nội dung ấy, chỉ là có sớm hơn.

Vì vậy, /tabs/used-good-view?id=... được tách làm hai nhánh. Trình duyệt nhận Expo shell. Crawler nhận một tài liệu HTML thật sự: một thẻ <h1>, một thẻ <dl> liệt kê thông tin (giá, tình trạng, danh mục, địa điểm, còn hàng hay không, số ảnh, ngày đăng), mỗi ảnh một thẻ <img> kèm alt text tự sinh, một đoạn trích nội dung, và link sang tám listing cùng nhóm để trang không bị mồ côi.

Phần cuối này hoá ra quan trọng hơn tôi nghĩ. Trước khi có mấy internal link đó, khoảng một nghìn trang listing nằm trơ trọi, không có link nào trỏ tới, và Search Console cho thấy lượng crawl chững lại ở mức bảy đến mười một request mỗi ngày. Crawler mà không tìm thấy trang của bạn thì trang có hay đến mấy cũng vô nghĩa.

Cái bẫy: tên công ty không phải là tên bot
#

Nhận diện crawler thực chất là so khớp user-agent, và tôi dính đòn đúng ở bước này.

Các nền tảng Hàn Quốc cho crawler chạy dưới những cái tên chẳng liên quan gì tới tên công ty. Crawler của Naver tên là Yeti. Của Daum là Daumoa. Còn scraper lấy preview link của Kakao là kakaotalk-scrap. Không cái nào mang đúng tên công ty.

Trong khi đó, cũng chính mấy công ty này làm ứng dụng có trình duyệt nhúng bên trong, và trình duyệt đó gửi user-agent y như trình duyệt thật, chỉ gắn thêm tên ứng dụng ở cuối. Trình duyệt trong ứng dụng KakaoTalk ghi KAKAOTALK/10.5.1. Scraper của Kakao thì ghi kakaotalk-scrap/1.0.

Cứ so khớp theo kakao là bạn tóm luôn cả hai. Tôi đã làm đúng như vậy, thế là người dùng KakaoTalk, ứng dụng Naver hay ứng dụng Daum nào mở link cũng nhận về trang dành cho crawler: không JavaScript, không điều hướng, không đăng nhập. Một tài liệu hoàn toàn chuẩn chỉnh, nhưng với con người thì vô dụng.

Cách sửa là một quy tắc về thứ tự kiểm tra, và đáng để viết hẳn ra thành quy tắc:

def is_crawler(request):
    """An in-app browser is never a crawler, even when its UA carries the
    same vendor name the vendor's bot does — so that check comes first."""
    ua = request.META.get("HTTP_USER_AGENT", "")
    if not ua or _INAPP_BROWSER_RE.search(ua):
        return False
    return bool(_CRAWLER_RE.search(ua))

Trình duyệt trong ứng dụng được kiểm tra trước, và luôn thắng. Còn token crawler nào cũng phải là tên của một bot, tuyệt đối không phải tên công ty:

# Korean engines. Naver crawls as Yeti; Daum as Daumoa; Kakao's link-preview
# scraper as kakaotalk-scrap. None of the three is the bare vendor name.
r"yeti/|naverbot|daumoa|kakaotalk-scrap|kakao[a-z]*bot|"

Rồi tôi đặt Cloudflare đứng trước
#

Origin chỉ là một machine shared-cpu duy nhất đặt ở Sydney, chịu tải ổn định được khoảng hai mươi đến ba mươi request mỗi giây. Googlebot có chừng 995 URL cần đi qua. URL nào cũng chạy thẳng về Sydney, vì cf-cache-status ở tất cả đều là DYNAMIC: Cloudflare không cache HTML trừ khi có rule bảo nó làm vậy. Cả zone chỉ khoảng 16% được cache, và phần quan trọng thì không nằm trong số đó.

Vậy thì thêm một cache rule cho các trang crawler. Đơn giản. Có điều, một URL listing không chỉ có một body. Nó có tới ba.

  • Crawler xin HTML thì nhận trang render ở server.
  • Người dùng thật thì nhận Expo shell.
  • Agent gửi Accept: text/markdown thì nhận markdown dựng từ cơ sở dữ liệu, vì đem một HTML shell rỗng đi chuyển đổi thì cũng chỉ ra một tài liệu rỗng.

Origin có trả Vary: Accept, còn Vary theo user-agent thì đằng nào cũng vô vọng. Và đây là điều tôi không hề biết:

Cloudflare bỏ qua Vary với HTML. Cả ba body dùng chung một cache key.

Body nào tới trước sẽ chiếm luôn URL đó cho mọi lượt truy cập sau ở edge location ấy, trong suốt một tiếng mà s-maxage của origin cho phép, cộng thêm một ngày stale-while-revalidate.

Tôi được tận mắt thấy chuyện này trên production, theo cả hai chiều:

  • Một người dùng ứng dụng Naver mở một URL listing trước, cache giữ lại trang crawler, rồi sau đó Safari bình thường trên iPhone nhận đúng trang ấy dưới dạng HIT. Một con người ngồi nhìn một tài liệu không có tí JavaScript nào.
  • Một người dùng thật mở một URL trước, cache giữ lại Expo shell, và Googlebot nhận về cái shell rỗng. Đúng cái lỗi mà cả nỗ lực SEO sinh ra để ngăn chặn, giờ lại còn được phục vụ nhanh hơn, ngay từ edge.

Cái rule sửa lỗi bằng cách thu hẹp phạm vi
#

Bạn không thể bắt Cloudflare tôn trọng Vary với HTML. Cái bạn làm được là đảm bảo trong ba body đó, chỉ đúng một cái có cửa vào cache:

(starts_with(http.request.uri.path, "/tabs/")
 and not any(http.request.headers["accept"][*] contains "text/markdown")
 and (lower(http.user_agent) contains "googlebot" or ...)
 and not (lower(http.user_agent) contains "kakaotalk/" or ...))

Chỉ khớp user-agent của crawler, và loại trừ request xin markdown. Giờ request của người dùng thật không khớp rule nào cả, nên nó là DYNAMIC và đi về origin như trước. Chỉ request của crawler mới được đọc hoặc ghi cache, nên body duy nhất có thể nằm trong đó là body dành cho crawler.

Edge TTL để là respect_origin, nên thời gian sống của cache vẫn do chính phần mã render trang quyết định, chứ không bị đóng cứng trong một cái dashboard nào đó.

Có một chi tiết vận hành rất dễ bỏ sót: thu hẹp rule không xoá những gì đã nằm sẵn trong cache. Bạn phải purge, không thì vẫn tiếp tục trả ra những entry nhiễm độc mà mình vừa ngừng tạo ra.

Hai danh sách, và vì sao sai lệch giữa chúng không đối xứng
#

Rule này cần một danh sách user-agent của crawler, mà Django thì đã có sẵn một danh sách như vậy. Cùng một kiến thức mà nằm hai bản ở hai hệ thống thường là dấu hiệu có vấn đề. Nhưng ở đây, điều thú vị là chuyện gì xảy ra khi hai bản lệch nhau, vì hậu quả không đối xứng:

  • Danh sách bot trong Cloudflare phải luôn là tập con của danh sách trong Django. Nếu có token mà Cloudflare coi là bot còn Django thì không, SPA shell sẽ bị cache dưới key chỉ dành cho crawler, rồi bị trả cho một crawler thật.
  • Danh sách loại trừ trình duyệt trong ứng dụng phải luôn là tập cha của danh sách trong Django. Loại trừ thừa thì cùng lắm bạn mất một lần cache hit.

Sai theo chiều an toàn, dù ở danh sách nào, thì bạn chỉ mất một ít cache. Nhưng sai danh sách đầu theo chiều ngược lại là bạn quay về cảnh trả cho Googlebot một trang trống. Nhân tiện, cũng vì sự bất đối xứng đó mà hai danh sách dùng contains chứ không dùng regex: matches đòi gói Business, còn contains là đủ cho một danh sách được phép xấp xỉ, miễn là lệch về phía đã biết là an toàn.

Một bất biến làm hỏng build thay vì làm hỏng trang
#

Các trang crawler nhúng URL ảnh dạng presigned, có hiệu lực bảy ngày. Còn bản thân các trang thì được cache ở edge một tiếng, cộng thêm một ngày stale-while-revalidate.

Hai con số này dính dáng tới nhau. Nếu có ngày thời gian cache vượt quá thời hạn của ảnh, edge sẽ bắt đầu trả ra những trang mà ảnh nào cũng dính lỗi 403. Sẽ chẳng có cảnh báo nào kêu cả, vì HTML vẫn hoàn toàn bình thường.

Nên module giữ mấy hằng số đó sẽ từ chối import nếu quan hệ này bị phá vỡ:

CRAWLER_IMAGE_TTL = 7 days
# raises at import if s-maxage + stale-while-revalidate >= CRAWLER_IMAGE_TTL

Một ràng buộc giữa hai hằng số nằm ở hai tệp khác nhau thì gần như vô hình khi code review, nhưng lộ ra ngay khi process khởi động. Vậy thì cứ đặt phép kiểm tra ở chỗ process khởi động.

Hai thứ Cloudflare làm mà tôi phải gỡ lại
#

Vary: Origin khiến bundle không vào được cache. django-cors-headers đóng dấu Vary: Origin lên mọi response mà nó đi qua, còn Cloudflare thì không cache bất cứ thứ gì vary theo nhiều hơn Accept-Encoding. Thế là bundle JavaScript 1.6 MB — thứ nặng nhất trên critical path — lặng lẽ bị loại hẳn khỏi edge. Cách sửa là một middleware đặt đầu tiên trong danh sách, để nó chạy sau cùng lúc response đi ra, và viết lại Vary cho các đường dẫn asset /_expo/.

Nén ở edge còn tệ hơn nén ở origin. Để Cloudflare tự nén Brotli on the fly thì ra 2,041,742 byte, trong khi gzip của chính origin chỉ ra 1,658,115. To hơn hai mươi ba phần trăm, chỉ để đổi lấy cái sướng là không phải nghĩ ngợi gì. Nén on the fly tối ưu cho CPU chứ không tối ưu cho tỉ lệ nén; asset nén sẵn từ origin vẫn thắng.

Báo cho công cụ tìm kiếm mà không cần chờ bị crawl
#

Có hai giao thức, nhưng gộp cả hai lại thì phạm vi phủ vẫn ít đến đáng thất vọng.

IndexNow chỉ cần một cú ping là phủ được Bing, Yandex và Seznam. Google và Naver thì không tham gia. Tôi cho nó chạy trên post_save trong một daemon thread với timeout năm giây; nó bỏ qua listing đã bán hoặc đã đóng, và không làm gì cả nếu chưa có key. Gặp lỗi mạng thì nó ghi một dòng warning vào log và không bao giờ làm hỏng lần lưu đã kích hoạt nó. Với một kênh phụ kiểu best-effort, đó là cách hành xử duy nhất chấp nhận được.

Indexing API của Google chỉ nhận đúng hai loại schema, và một trong hai là JobPosting, thứ mà tôi lại có. Tin tuyển dụng còn mở thì gửi URL_UPDATED, tin đã đóng thì gửi URL_DELETED. Lệnh xoá đó chỉ trung thực vì listing đã đóng cũng trả noindex, và mã nguồn có ghi chú rõ chuyện này, để lỡ ai gỡ noindex đi thì cũng biết mình còn làm hỏng thêm thứ gì.

Có hai chỗ làm tôi mất thời gian. Thứ nhất, quota là 200 URL mỗi ngày, mà một lần chạy import từ scraper bắn signal hàng trăm lần — nên listing import phải được bỏ qua, không thì một lần import đốt sạch ngân sách của cả ngày. Thứ hai, endpoint là urlNotifications:publish, dùng dấu hai chấm. Dùng dấu gạch chéo là Google trả về một trang HTML 404 trơn, trông y hệt như API đang bị tắt.

Structured data dạy tôi những gì
#

Ba quy tắc, cái nào cũng học được sau khi bị Search Console phàn nàn:

  1. Không parse được thì bỏ ra. Mức lương gửi đến dưới dạng text tự do. Trước đây tôi nhét nguyên chuỗi đó vào baseSalary.value, vốn không hợp lệ, thế là tin tuyển dụng nào cũng bị gắn cờ thiếu đơn vị. Giờ lương nào không parse được thì đơn giản là không có.
  2. Đừng bao giờ bịa ra một field chỉ để chiều schema. Tin tuyển dụng hiếm khi ghi địa chỉ cụ thể đến tên đường. Rất dễ bị cám dỗ bịa ra một địa chỉ nghe hợp lý cho PostalAddress. Nhưng một địa chỉ bịa trên tin tuyển dụng còn tệ hơn một địa chỉ thiếu, nên tôi chỉ đưa vào locality và quốc gia.
  3. Ngày hết hạn không phải là tuỳ chọn. Google bỏ qua JobPosting nào không có validThrough, nên tôi tự suy ra một giá trị, kèm mức sàn để một listing cũ không tự nhận là đã hết hạn từ hôm qua.

Còn quy tắc thứ tư, về sitemap: đừng khai changefreq: daily cho mọi thứ. Bảo Googlebot crawl lại một nghìn listing mỗi ngày thì trong mắt nó, host của bạn đang quá tải, và nó sẽ đáp lại bằng cách giảm tốc độ crawl cho cả trang web. Hãy suy ra giá trị này từ lần gần nhất nội dung thực sự thay đổi.

Sitemap cũng có một mức sàn về chất lượng: tiêu đề và nội dung cộng lại phải đủ bốn mươi ký tự. Search Console từng báo khoảng một nghìn listing ở trạng thái “Crawled – currently not indexed”, và có một trang đồ cũ mà toàn bộ nội dung riêng của nó chỉ gồm một tiêu đề đúng một chữ và một số điện thoại. Bốn mươi ký tự là tôi cố tình đặt thấp, vì 471 trên 484 listing viết bằng tiếng Hàn, mà tiếng Hàn chứa được nhiều ý hơn trên mỗi ký tự; một ngưỡng canh theo tiếng Anh sẽ loại mất một nửa marketplace. Listing nào nội dung mỏng thì vẫn crawl được qua các trang index, và sẽ tự động quay lại sitemap khi có người sửa chúng.

Tôi rút ra được gì
#

  • Trả nhiều body khác nhau ở cùng một URL trước hết là bài toán cache key, rồi mới đến chuyện khác. Hãy tìm hiểu xem CDN của bạn lấy key theo những gì, và cứ giả định nó bỏ qua Vary cho đến khi bạn chứng minh được điều ngược lại với đúng loại content type đó.
  • Khi hai hệ thống phải thống nhất về một danh sách, hãy xác định chiều sai nào là an toàn, rồi ghi điều đó ngay cạnh cả hai bản. Nói thẳng “hai bản này có thể lệch nhau, và đây là cách duy nhất chúng được phép lệch” vẫn hơn là giả vờ chúng không bao giờ lệch.
  • So khớp bot theo tên bot, đừng bao giờ theo tên công ty. Nhất là ở ngoài thế giới nói tiếng Anh, nơi chính công ty làm bot cũng làm luôn cái trình duyệt mà người dùng của bạn đang cầm trên tay.
  • Bất biến giữa những hằng số nằm xa nhau thì hãy đặt ở chỗ nào hỏng thật to. Kiểm tra lúc import chẳng tốn gì, và không ai lách qua được.