Mọi phần của Kidslen tới giờ đều là những lựa chọn thiết kế tôi thấy thoải mái khi bảo vệ. Bài này nói về phần làm tôi mất ngủ.

Để cho phụ huynh thấy con họ thật sự đã xem gì, tôi cần lịch sử xem từ sáu nền tảng — YouTube, Netflix, TikTok, Instagram, Disney+, Prime Video. Không nền tảng nào cung cấp API kiểu “cho phép một sản phẩm giám sát đọc lịch sử xem của con tôi”. Nên cơ chế là thứ mà mọi người trong ngành này rốt cuộc đều đi tới, dù có nói công khai hay không: một WebView, chính phiên đăng nhập của đứa trẻ, và JavaScript tiêm vào.

Để tôi kể nó hoạt động ra sao, rồi nói thật nó sai ở đâu.

Khung đồng ý, trước đã

Trước khi bàn cơ chế: luồng kết nối là tường minh và hai chiều. Đứa trẻ đăng nhập vào chính tài khoản của nó trong một trình duyệt in-app nhìn thấy được, sau khi đã được cho xem — trên màn hình, trước khi trình duyệt mở — những gì sẽ được đọc từ nền tảng đó. Companion app mang banner thường trực “đang được giám sát bởi <phụ huynh>” và một màn hình minh bạch liệt kê từng nền tảng đã kết nối cùng những gì thu thập từ nó. Cả hai phía đều ngắt kết nối một nền tảng được bất cứ lúc nào, và ngắt kết nối là thu hồi phiên đã lưu.

Điều này quan trọng ở đây hơn bất kỳ chỗ nào khác trong sản phẩm, vì những gì tiếp theo là mô tả việc đọc dữ liệu tài khoản của một người. Thứ duy nhất phân biệt nó với loại spyware tôi phàn nàn ở Phần 1 là chủ tài khoản đang nhìn nó diễn ra, và có thể dừng nó.

”Kết nối tài khoản” thật ra làm gì

Khi một đứa trẻ kết nối YouTube, app mở một WebView trỏ tới đúng trang đăng nhập web bình thường của nền tảng. Không proxy gì, không giả gì — đó là trang login thật, 2FA thật, màn hình đồng ý thật. Đứa trẻ gõ mật khẩu vào chính trang của nền tảng.

App tiêm hai script vào WebView đó:

  • một script chung, dùng cho cả sáu nền tảng: state machine, giao thức message, báo lỗi, các tiện ích về thời gian;
  • một script riêng theo nền tảng: mọi thứ biết “đã đăng nhập” trông thế nào trên site cụ thể này, endpoint nào giữ lịch sử xem, và các trường nằm ở đâu trong response.

Sự tách đôi này quan trọng. Nửa chung thì ổn định — tôi hiếm khi sửa. Nửa riêng theo nền tảng mới là phần mục rữa, và cô lập nó nghĩa là một nền tảng hỏng chỉ cần một sửa đổi nhỏ, gọn, thay vì mổ vào code dùng chung.

State machine sống trong trang

Luồng kết nối là một state machine với đại khái các trạng thái:

INIT → AWAITING_LOGIN → LOGGED_IN → PROBING → HARVESTING → COMPLETE

cộng FAILED và NEEDS_REAUTH.

Quyết định không hiển nhiên: trạng thái được lưu trong localStorage của chính trang đó, không phải trong bộ nhớ native.

Lý do là điều hướng. Một luồng đăng nhập không phải một trang. Nó là trang login, trang mật khẩu, một trang trung gian, một thử thách 2FA, một màn hình đồng ý, một redirect, và cuối cùng là chính ứng dụng web. Mỗi cái là một document mới toanh, và script tôi tiêm vào bị chạy lại từ đầu mỗi lần, không nhớ gì về trước đó. Giữ trạng thái ở native rồi đẩy xuống mỗi lần điều hướng nghĩa là có một cuộc đua ở mỗi lần load trang. Giữ nó trong localStorage — cùng origin với nền tảng, nên sống sót qua mọi điều hướng nội bộ site — nghĩa là hành động đầu tiên của script trên bất kỳ trang nào là đọc xem nó đang ở đâu.

Vậy nên INIT không phải là “bắt đầu luồng”. Nó là “xem luồng này đã đang chạy chưa, nếu rồi thì tiếp tục”.

Bản ghi trạng thái rất nhỏ: trạng thái hiện tại, các bộ đếm số lần thử, một session id nối ngược về phía native, và timestamp cho timeout. Không bao giờ có credentials. Không có gì nhạy cảm được lưu trong trang.

Giao thức message

Giao tiếp với native là một message JSON qua cầu WebView, một hình dạng duy nhất cho mọi thứ:

{
  "v": 1,
  "sessionId": "…",
  "type": "STATE_CHANGED",
  "platform": "youtube",
  "payload": { "from": "AWAITING_LOGIN", "to": "LOGGED_IN" },
  "ts": 1757400000000
}

Các loại message, đại khái:

LoạiChiềuÝ nghĩa
READYtrang → nativeScript đã tiêm và đang chạy trên document này
STATE_CHANGEDtrang → nativeChuyển trạng thái, dùng cho UI và chẩn đoán
NEEDS_USER_ACTIONtrang → native2FA hoặc một thử thách — native hiện WebView lên và tắt lớp phủ
SESSION_CAPTUREDtrang → nativeĐăng nhập thành công; đã có vật liệu phiên
DATAtrang → nativeMột lô mục lịch sử xem đã chuẩn hoá
ERRORtrang → nativeSelector trượt, hình dạng endpoint đổi, request lỗi
CONFIGnative → trangMetadata theo nền tảng: endpoint, key path, giới hạn
ABORTnative → trangNgười dùng huỷ, hoặc timeout — dọn dẹp và thoát sạch

Đánh version cho phong bì message (v) đã chứng minh giá trị ngay trong tháng đầu. Vì script được phục vụ từ backend (xem bên dưới) còn app trên máy là phiên bản đứa trẻ cập nhật lần cuối, hai phía trôi lệch nhau. Trường version cho phép native từ chối một hình dạng message nó không hiểu, thay vì diễn giải sai.

Một chuỗi thành công điển hình:

native:  mở WebView → tiêm script chung + script nền tảng
trang:   READY                     (trang login)
trang:   STATE_CHANGED  INIT → AWAITING_LOGIN
  … người dùng gõ mật khẩu …
trang:   NEEDS_USER_ACTION  { reason: "2fa" }
  … người dùng hoàn tất 2FA …
trang:   STATE_CHANGED  AWAITING_LOGIN → LOGGED_IN
trang:   SESSION_CAPTURED
native:  CONFIG  { endpoints, keyPaths, pageSize }
trang:   STATE_CHANGED  LOGGED_IN → PROBING
trang:   STATE_CHANGED  PROBING → HARVESTING
trang:   DATA  { items: [ … ] }     × n
trang:   STATE_CHANGED  HARVESTING → COMPLETE
native:  đưa các mục vào hàng đợi upload → đóng WebView

Đây là phần thật sự thú vị.

Những cookie phiên quan trọng đều là HttpOnly. Cờ đó tồn tại chính xác để JavaScript trong trang không đọc được chúng — đó là hàng phòng thủ của trình duyệt chống script ăn cắp phiên. Script tôi tiêm vào chính là JavaScript trong trang. document.cookie không cho nó thấy gì.

Lời giải là script tiêm vào không bao giờ cần thấy chúng. Tầng native sở hữu cookie store của WebView, và trên cả hai nền tảng di động, code native đọc được cookie mà trang không đọc được — đó là năng lực bình thường của một app host một WebView. Nên khi trang gửi SESSION_CAPTURED, native đọc vật liệu phiên từ cookie store của WebView và cất vào keychain/keystore của hệ điều hành trên máy.

Hai hệ quả tôi muốn nói thẳng:

  1. Vật liệu phiên ở lại trên máy của đứa trẻ. Nó được dùng để gọi request từ chính máy đó. Đưa cookie ra khỏi điện thoại sẽ biến tôi thành mục tiêu rất hấp dẫn để đổi lấy rất ít lợi ích sản phẩm.
  2. Script không bao giờ chạm credentials. Chúng quan sát điều hướng và trạng thái DOM, rồi gọi các endpoint mà trình duyệt tự phân quyền bằng cookie nó tự đính kèm. Kể cả một script capture bị chiếm hoàn toàn cũng không rút được mật khẩu, vì nó chưa từng chạm tới mật khẩu nào.

Rồi: gọi chính endpoint của nền tảng

Khi đã có phiên, cào DOM là công cụ sai. Trang render là lazy, ảo hoá, cuộn vô hạn và bị A/B test; đọc chúng vừa chậm, vừa nhiễu, vừa hỏng liên tục.

Thay vào đó, script chạy trong ngữ cảnh trang gọi chính những endpoint nội bộ mà web app của nền tảng gọi — những cái nằm sau trang lịch sử. Cùng origin, cùng cookie, cùng header mà trang sẽ gửi, vì request đúng là do trang gửi. Response là JSON, có phân trang, đầy đủ.

Phần tôi hơi tự hào là script không hardcode vị trí các trường. Nó nhận key path do metadata điều khiển trong message CONFIG:

{
  "endpoint": "/…/history",
  "keyPaths": {
    "items":    "contents.sectionList.items",
    "title":    "meta.title.text",
    "id":       "meta.videoId",
    "watchedAt":"meta.watchedTimestamp",
    "duration": "meta.lengthSeconds"
  },
  "pageSize": 50
}

Script đi theo các đường dẫn đó một cách tổng quát và phát ra mục đã chuẩn hoá. Khi một nền tảng xáo lại response — và họ có xáo — cách sửa thường là một chuỗi trong một dòng config, không phải sửa code. Khi hình dạng đổi sâu hơn, đó là một sửa đổi trong đúng một script nền tảng.

Nói thật: cái này mong manh

Cần nói thẳng, vì một bài trình bày cái này như kỹ thuật vững chắc sẽ là bài không trung thực.

  • Mọi nền tảng có thể đổi DOM, đổi endpoint, đổi hình dạng response hoặc luồng xác thực bất cứ lúc nào, không báo trước, và không ai nợ tôi điều gì.
  • Một selector chạy tốt nhiều tháng có thể vỡ vào một chiều thứ Ba với một nhóm người dùng trong một A/B test tôi không nhìn thấy.
  • Các biện pháp chống tự động hoá có thể siết chặt thêm.
  • Hỏng hóc thường là một phần — một nền tảng ngừng báo cáo trong khi năm cái còn lại vẫn ổn, đúng cái chế độ hỏng âm thầm mà tôi nói mình sợ ở Phần 3.

Tôi không tìm ra cách đi vòng qua chuyện này. Chưa ai trong ngành này tìm ra. Thứ tôi làm thay vào đó là thiết kế dựa trên tiền đề rằng nó sẽ hỏng.

Biện pháp giảm thiểu thật sự quan trọng

Script capture được phục vụ từ backend, có version, và companion app tải về lúc chạy. Chúng không được biên dịch vào binary của app.

Riêng quyết định đó thay đổi toàn bộ kinh tế học của mọi lần hỏng:

Script nằm trong binaryScript phục vụ từ backend
Sửa một selector hỏngSửa code, build, chờ duyệt store, chờ, và mong người dùng cập nhậtSửa script, deploy, máy nhận ở lần fetch kế tiếp
Thời gian phục hồiNhanh nhất là vài ngày, và chỉ cho người đã cập nhậtVài giờ
Người dùng vẫn hỏng sau khi sửaTất cả những ai chưa cập nhậtKhông ai
Rollback một bản sửa tồiMột bản phát hành nữaDeploy lại phiên bản trước

Độ trễ duyệt app store ở đây không phải chi tiết vặt; nó là khác biệt giữa một bug kéo dài một buổi chiều và một bug kéo dài hai tuần với một nửa số người dùng. Đưa phần code dễ vỡ đi qua pipeline deploy của chính mình thay vì quy trình phát hành của người khác là quyết định cấu trúc quan trọng nhất trong companion app.

Các mảnh hỗ trợ:

  • Script có version và được ghim theo từng nền tảng, nên tôi tung được bản sửa và rút lại được.
  • Message ERROR mang theo key path hoặc selector nào đã trượt, nên một lần hỏng hiện lên trong metric và trên Grafana thành “tỷ lệ lỗi capture YouTube”, chứ không thành một khoảng trống âm thầm trong lịch sử của ai đó.
  • Một nền tảng hỏng thì suy giảm một mình; năm cái còn lại vẫn báo cáo.
  • Dashboard phụ huynh hiển thị trung thực sức khoẻ kết nối của từng nền tảng, kể cả trạng thái “nền tảng này đã ngừng báo cáo”, thay vì hiện một biểu đồ rỗng ngụ ý rằng đứa trẻ không xem gì.

Cái cuối là nguyên tắc sản phẩm, không phải nguyên tắc kỹ thuật. Trong một sản phẩm giám sát, “không có dữ liệu” và “không có hoạt động” tuyệt đối không được vẽ giống nhau. Một lần capture hỏng âm thầm trông như một buổi tối yên tĩnh chính là cách bạn khiến một phụ huynh buộc tội con mình dựa trên bug của tôi.

Điều tôi sẽ nói với người định xây thứ này

Cơ chế không phải phần thông minh — WebView cộng script tiêm vào là câu trả lời hiển nhiên khi bạn nhìn vào bài toán. Phần thông minh là thừa nhận cơ chế đó bất ổn và xây toàn bộ hệ thống quanh việc phục hồi nhanh: cô lập phần code dễ vỡ, phục vụ nó từ nơi bạn kiểm soát, đánh version cho giao thức, đo đạc mọi lần hỏng, và làm cho hỏng-một-phần trở nên nhìn thấy được thay vì vô hình.

Bài tiếp theo: backend, dashboard phụ huynh, ops console và companion app được xây song song bằng AI agent dựa trên một bản hợp đồng API duy nhất ra sao — và tôi đã tự tay kiểm chứng lại những gì thay vì tin báo cáo.

Sản phẩm ở kidslen.app.

Xuất nội dung

Bình luận