↓ Chuyển đến nội dung chính

Cloudflare SEO Django

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

Chuyện làm cho một ứng dụng JavaScript hiện ra trên Naver và Google, và cái quy tắc lưu đệm đã lặng lẽ trao nhầm trang của bên này cho bên kia.

Sàn mua bán trực tuyến (marketplace) của tôi là một ứng dụng React Native Web, xuất bản bằng Expo. Trước khi JavaScript chạy, trang tin đăng (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 trình thu thập dữ liệu (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 quy tắc lưu đệm (cache rule) phá hỏng tất cả theo cách tôi không hề lường trước.

Kết xuất động, chứ không phải che giấu nội dung
#

Cách sửa thì chẳng có gì mới, và được chính Google chấp nhận dưới cái tên kết xuất động (dynamic rendering), khác hẳn với che giấu nội dung (cloaking): nhận diện trình thu thập dữ liệu, rồi trả cho nó HTML đã kết xuất (render) sẵn ở máy chủ, 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 phần khung (shell) của Expo. Trình thu thập dữ liệu 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 văn bản thay thế (alt text) tự sinh, một đoạn trích nội dung, và liên kết sang tám tin đăng 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 liên kết nội bộ đó, khoảng một nghìn trang tin đăng nằm trơ trọi, không có liên kết nào trỏ tới, và Search Console cho thấy lượng thu thập chững lại ở mức bảy đến mười một yêu cầu mỗi ngày. Trình thu thập dữ liệu 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 trình thu thập dữ liệu thực chất là so khớp chuỗi user-agent, và tôi dính đòn đúng ở bước này.

Các nền tảng Hàn Quốc cho trình thu thập dữ liệu chạy dưới những cái tên chẳng liên quan gì tới tên công ty. Trình thu thập của Naver tên là Yeti. Của Daum là Daumoa. Còn công cụ cào dữ liệu (scraper) lấy bản xem trước liên kết 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. Công cụ cào dữ liệu 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ở liên kết cũng nhận về trang dành cho trình thu thập: 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 nhận diện trình thu thập 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
#

Máy chủ gốc (origin) chỉ là một máy shared-cpu duy nhất đặt ở Sydney, chịu tải ổn định được khoảng hai mươi đến ba mươi yêu cầu 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 lưu đệm HTML trừ khi có quy tắc bảo nó làm vậy. Cả vùng (zone) chỉ khoảng 16% được lưu đệm, và phần quan trọng thì không nằm trong số đó.

Vậy thì thêm một quy tắc lưu đệm cho các trang dành cho trình thu thập. Đơn giản. Có điều, một URL tin đăng không chỉ có một phần thân (body). Nó có tới ba.

  • Trình thu thập dữ liệu xin HTML thì nhận trang kết xuất ở máy chủ.
  • Người dùng thật thì nhận phần khung của Expo.
  • Tác tử AI (AI agent) gửi Accept: text/markdown thì nhận markdown dựng từ cơ sở dữ liệu, vì đem một khung HTML rỗng đi chuyển đổi thì cũng chỉ ra một tài liệu rỗng.

Máy chủ gốc 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 phần thân dùng chung một khoá lưu đệm (cache key).

Phần thân nào tới trước sẽ chiếm luôn URL đó cho mọi lượt truy cập sau ở điểm biên (edge location) ấy, trong suốt một tiếng mà s-maxage của máy chủ gốc 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 môi trường production, theo cả hai chiều:

  • Một người dùng ứng dụng Naver mở một URL tin đăng trước, bộ nhớ đệm giữ lại trang dành cho trình thu thập, 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, bộ nhớ đệm giữ lại phần khung của Expo, và Googlebot nhận về cái khung 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ừ máy chủ biên.

Cái quy tắc 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 phần thân đó, chỉ đúng một cái có cửa vào bộ nhớ đệm:

(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 trình thu thập, và loại trừ yêu cầu xin markdown. Giờ yêu cầu của người dùng thật không khớp quy tắc nào cả, nên nó là DYNAMIC và đi về máy chủ gốc như trước. Chỉ yêu cầu của trình thu thập mới được đọc hoặc ghi bộ nhớ đệm, nên phần thân duy nhất có thể nằm trong đó là phần thân dành cho trình thu thập.

Edge TTL để là respect_origin, nên thời gian sống của dữ liệu đệm vẫn do chính phần mã kết xuất trang quyết định, chứ không bị đóng cứng trong một bảng điều khiển nào đó.

Có một chi tiết vận hành rất dễ bỏ sót: thu hẹp quy tắc không xoá những gì đã nằm sẵn trong bộ nhớ đệm. Bạn phải xoá bộ nhớ đệm (purge), không thì vẫn tiếp tục trả ra những mục 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
#

Quy tắc này cần một danh sách user-agent của trình thu thập, 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, phần khung SPA sẽ bị lưu đệm dưới khoá chỉ dành cho trình thu thập, rồi bị trả cho một trình thu thập 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 trúng bộ nhớ đệm (cache hit).

Sai theo chiều an toàn, dù ở danh sách nào, thì bạn chỉ mất một ít dữ liệu đệm. 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 biểu thức chính quy (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 bản dựng thay vì làm hỏng trang
#

Các trang dành cho trình thu thập nhúng URL ảnh dạng ký sẵn (presigned), có hiệu lực bảy ngày. Còn bản thân các trang thì được lưu đệm ở máy chủ biên 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 lưu đệm vượt quá thời hạn của ảnh, máy chủ biên 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 mô-đun giữ mấy hằng số đó sẽ từ chối nạp (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 rà soát mã, nhưng lộ ra ngay khi tiến trình khởi động. Vậy thì cứ đặt phép kiểm tra ở chỗ tiến trình khởi động.

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

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

Nén ở máy chủ biên còn tệ hơn nén ở máy chủ gốc. Để Cloudflare tự nén Brotli tức thời thì ra 2,041,742 byte, trong khi gzip của chính máy chủ gốc 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 tức thời tối ưu cho CPU chứ không tối ưu cho tỉ lệ nén; tài nguyên nén sẵn từ máy chủ gốc vẫn thắng.

Báo cho công cụ tìm kiếm mà không cần chờ được thu thập
#

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 luồng nền (daemon thread) với thời gian chờ tối đa năm giây; nó bỏ qua tin đăng đã bán hoặc đã đóng, và không làm gì cả nếu chưa có khoá. Gặp lỗi mạng thì nó ghi một dòng cảnh báo vào nhật ký 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 làm được đến đâu hay đến đó (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ì tin đăng đã đó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, hạn mức (quota) là 200 URL mỗi ngày, mà một lần chạy nhập dữ liệu từ công cụ cào bắn signal hàng trăm lần — nên tin đăng nhập vào phải được bỏ qua, không thì một lần nhập đốt sạch hạn mức 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.

Dữ liệu có cấu trúc dạy tôi những gì
#

Ba quy tắc về dữ liệu có cấu trúc (structured data), cái nào cũng học được sau khi bị Search Console phàn nàn:

  1. Không phân tích được thì bỏ ra. Mức lương gửi đến dưới dạng văn bản 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 phân tích (parse) được thì đơn giản là không có.
  2. Đừng bao giờ bịa ra một trường 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 khu vực (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 tin đăng cũ không tự nhận là đã hết hạn từ hôm qua.

Còn quy tắc thứ tư, về sơ đồ trang web (sitemap): đừng khai changefreq: daily cho mọi thứ. Bảo Googlebot thu thập lại một nghìn tin đăng mỗi ngày thì trong mắt nó, máy chủ của bạn đang quá tải, và nó sẽ đáp lại bằng cách giảm tốc độ thu thập 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.

Sơ đồ trang web 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 tin đăng ở 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 tin đăng 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 sàn mua bán. Tin đăng nào nội dung mỏng thì vẫn được thu thập qua các trang danh mục, và sẽ tự động quay lại sơ đồ trang web khi có người sửa chúng.

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

  • Trả nhiều phần thân khác nhau ở cùng một URL trước hết là bài toán khoá lưu đệm, rồi mới đến chuyện khác. Hãy tìm hiểu xem CDN của bạn lấy khoá 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 kiểu nội dung (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 nạp mô-đun chẳng tốn gì, và không ai lách qua được.