AI News Feed — API tin tức AI

API đọc tin tức hàng hóa & vĩ mô bằng tiếng Việt. Hai kênh: REST để đọc và phân trang, stream realtime (SSE) để nhận tin ngay khi có. Chỉ đọc, chỉ GET, xác thực bằng API key. Mỗi endpoint trong trang này có khối test gọi trực tiếp từ trình duyệt.

https://ai-news-api.sfi.com.vnREST
https://ai-news-socket.sfi.com.vnSTREAM

Tổng quan

Base URL — RESThttps://ai-news-api.sfi.com.vn
Base URL — Streamhttps://ai-news-socket.sfi.com.vn
Tiền tố phiên bản/v1 (REST). Stream không có tiền tố phiên bản
Định dạngJSON UTF-8 (có dấu tiếng Việt). Stream: text/event-stream
Phương thứcChỉ GET (và OPTIONS cho preflight CORS)
Xác thựcAuthorization: Bearer <key> hoặc X-API-Key: <key>
CORSMở cho mọi origin trên /v1/* và trên stream — gọi được thẳng từ trình duyệt
curl -H "Authorization: Bearer <key>" \
     "https://ai-news-api.sfi.com.vn/v1/news?limit=20"
Trong mọi danh sách, thân bài viết chỉ là một bản xem trước ngắn. Danh sách sinh ra để nói “có tin này, đây là id” càng nhẹ càng tốt, mà thân một bài viết dài hàng nghìn từ. Nguyên văn nằm ở đúng một nơi: GET /v1/news/{id}, gọi bằng chính cái id nhận được. Đọc cờ excerpt để biết thân bài đã đầy đủ hay chưa, đừng đoán theo endpoint.

⚡ Cấu hình test — dùng cho mọi khối test trong trang

Danh mục endpoint

EndpointServiceMô tảXác thực
GET /v1/health REST Health check KHÔNG CẦN KEY
GET /v1/news REST Danh sách — vừa để duyệt / tìm (con trỏ hai chiều, lọc theo sản phẩm, nhóm, khoảng ngày), vừa để poll tin mới: mặc định là các bản ghi mới nhất scope articles
GET /v1/news/{id} REST Chi tiết một bản ghi — nơi duy nhất lấy nguyên văn bài mà danh sách chỉ trả preview scope articles
GET /v1/products REST Danh mục sản phẩm — tra tên, nhóm, sàn của các mã trong products; cũng là danh sách mã hợp lệ để lọc scope articles
GET /health Stream Health check của service stream, kèm số subscriber đang kết nối KHÔNG CẦN KEY
GET /stream/news Stream Server-Sent Events — push realtime, tự reconnect scope articles

Không có endpoint ghi. Lọc được theo mã sản phẩm, nhóm và khoảng ngày (xem §5); không có tìm kiếm toàn văn và không lọc được theo nguồn tin.

1

REST API

4 endpoint trên service REST. Mỗi endpoint có khối test riêng, dùng Base URL / key ở khối Cấu hình test.

1.1 · Health check

GET KHÔNG CẦN KEY

/v1/health

Kiểm tra service REST còn sống. Không cần key — dùng cho monitor. Service stream có health riêng (GET /health trên base URL stream), trả thêm số subscriber đang kết nối.

Response 200

{ "ok": true }

Health của service stream:

{ "ok": true, "subscribers": 3 }

Test — health cả hai service

chưa gọi

1.2 · Danh sách tin

GET scope articles

/v1/news

Endpoint danh sách duy nhất, và nó trả lời cả hai câu hỏi: “kho có gì” (con trỏ chạy được hai chiều, lọc theo loại / mã sản phẩm / khoảng ngày) và “đang có tin gì mới” (mặc định mới nhất trước, nên gọi trần GET /v1/news chính là các bản ghi mới nhất — đúng thứ stream ở §2 đẩy tới bạn).

body là HTML (xem §3.1); như mọi danh sách, thân bài chỉ là bản xem trước ngắn (excerpt: true) — nguyên văn một bài lấy ở §1.3.

Query parameters

Tất cả đều tùy chọn và kết hợp được với nhau (điều kiện AND). Không truyền gì = 100 bản ghi mới nhất.

Tham sốKiểuMặc địnhGiải thích
productsstringkhông lọcDanh sách mã sản phẩm ngăn bằng dấu phẩy, ví dụ CPE,FEF. Khớp bất kỳ mã nào (OR), không phải tất cả. Không phân biệt hoa thường. Mã lạ không lỗi — chỉ là không khớp gì
categoriesstringkhông lọcLọc theo categories, nhiều giá trị ngăn bằng dấu phẩy, ví dụ Kim loại,Năng lượng. Khớp bất kỳ giá trị nào (OR). So sánh không phân biệt hoa thường và không phân biệt dấu tiếng Việt — kim loai, KIM LOẠI và Kim loại là một. Khớp nguyên giá trị, không khớp một phần (kim không khớp Kim loại). Giá trị lạ không lỗi — chỉ là không khớp gì
fromstringkhông giới hạnĐầu khoảng thời gian, tính trên fetched_at. Dạng ngày 2026-09-01 = từ 00:00 UTC hôm đó. Nhận cả timestamp ISO đầy đủ. Sai định dạng trả 400
tostringkhông giới hạnCuối khoảng. Dạng ngày bao gồm trọn ngày đó — to=2026-09-30 lấy hết 30/09. Nếu truyền timestamp đầy đủ thì mốc này là loại trừ
since_idinteger0Con trỏ “có gì mới”: chỉ trả bản ghi sẵn sàng sau bản ghi có id này. Truyền lại next_since_id của lần trước — đừng tự tính, xem cảnh báo dưới bảng. Giá trị âm bị kẹp về 0, không phải số thì bỏ qua
before_idinteger0Lật tiếp về quá khứ: chỉ trả bản ghi sẵn sàng trước bản ghi có id này. Truyền lại next_before_id của lần trước. 0 = không giới hạn
orderstringdescdesc mới nhất trước, asc cũ nhất trước. Giá trị lạ bị bỏ qua (giữ mặc định của endpoint)
limitinteger100Số bản ghi tối đa. Kẹp trong khoảng 1…100 — xin nhiều hơn 100 không báo lỗi, chỉ trả về 100
Khoảng ngày tính trên fetched_at, không phải published_at. published_at được giữ nguyên văn từ nguồn nên mỗi nguồn một định dạng ("2026-09-01T13:15:14.000Z" ở nguồn này, "Sun, 30 Aug 2026 05:49:24 GMT" ở nguồn kia, có nguồn không có) — lọc theo nó là so sánh định dạng chứ không phải so sánh ngày. fetched_at do hệ thống này ghi, luôn ISO-8601 UTC, và có trong mọi response nên bạn đọc lại được đúng thứ mình vừa lọc.
id lớn hơn không có nghĩa là mới hơn — đừng tự sinh con trỏ. Danh sách được sắp theo fetched_at, tức thời điểm bản ghi sẵn sàng để đọc. Còn id được cấp lúc hệ thống lấy tin về, trước khi dịch và tạo ảnh — nên một bài mất hai phút xử lý sẽ có id nhỏ hơn mọi tin khác xuất hiện trong lúc nó đang được xử lý, mà vẫn mới hơn tất cả.

Vì vậy since_id/before_id chỉ một bản ghi chứ không phải một mốc số học: server tra bản ghi đó rồi cắt trang tại vị trí của nó. Cứ truyền lại nguyên văn next_since_id/next_before_id thì luôn đúng; tự lấy max(id) trong items làm con trỏ thì sẽ bỏ sót đúng những bài xử lý lâu.

Ví dụ

# 20 tin mới nhất — cũng chính là cách poll
GET /v1/news?limit=20

# Poll vòng sau: chỉ những gì mới hơn con trỏ đã lưu
GET /v1/news?since_id=18342

# Bài viết về đồng hoặc quặng sắt trong tháng 9
GET /v1/news?products=CPE,FEF&from=2026-09-01&to=2026-09-30

# Tin thuộc nhóm Kim loại hoặc Năng lượng (gõ không dấu cũng được) — nhớ URL-encode
GET /v1/news?categories=kim%20loai,nang%20luong

# Lật tiếp về quá khứ: dùng next_before_id của trang trước
GET /v1/news?limit=20&before_id=18310

# Cũ nhất trước, nối tiếp từ con trỏ đã lưu
GET /v1/news?order=asc&since_id=18342

Response 200

{
  "items": [
    {
      "id": 18342,
      "title": "Đồng lên đỉnh ba tuần nhờ kỳ vọng nhu cầu Trung Quốc",
      "body": "<p>Giá đồng tăng lên mức cao nhất trong ba tuần khi thị trường kỳ vọng nhu cầu từ Trung Quốc phục hồi sau loạt biện pháp...</p>",
      "excerpt": true,
      "published_at": "2026-08-31T09:12:00+00:00",
      "fetched_at": "2026-08-31T09:20:14+00:00",
      "categories": ["Kim loại", "Kinh tế vĩ mô"],
      "country": "Trung Quốc",
      "products": ["CPE"],
      "content_kind": "article",
      "featured_image_url": "https://cdn.example.com/ai-news/18342.jpg",
      "metadata": {}
    }
  ],
  "next_since_id": 18342,
  "next_before_id": 18342,
  "count": 1
}

Field cấp ngoài

FieldKiểuGiải thích
itemsarrayMảng Article, đã sắp theo id theo chiều order yêu cầu (mặc định giảm dần ở endpoint này). Rỗng khi không còn bản ghi nào khớp
next_since_idnumberid của bản ghi mới nhất trong trang — truyền lại vào ?since_id= để hỏi “có gì mới hơn”. Khi items rỗng, trả lại đúng since_id bạn gửi lên, nên vòng lặp không bao giờ lùi
next_before_idnumberid của bản ghi cũ nhất trong trang — truyền lại vào ?before_id= để lật tiếp về quá khứ. Đây là cách phân trang khi sắp giảm dần
countnumberSố phần tử trong items. count < limit nghĩa là bạn đã đọc hết dữ liệu khớp bộ lọc
Không có total, và đó là chủ ý. Đếm tổng nghĩa là COUNT(*) quét toàn bảng ở mỗi request — đúng thứ mà thiết kế con trỏ này sinh ra để tránh. Dấu hiệu “hết dữ liệu” là count < limit, không phải một con số tổng.
Dùng nó để poll thì poll thưa thôi. Vòng đầu gọi trần /v1/news?limit=20, lưu next_since_id, các vòng sau truyền lại nó vào ?since_id= — như vậy mỗi vòng chỉ nhận phần mới và con trỏ không bao giờ lùi. Nếu bạn cần độ trễ thấp thì dùng stream (§2) — đó chính là thứ nó sinh ra để giải quyết; poll dày mỗi vài giây chỉ tốn tài nguyên hai bên mà vẫn chậm hơn.

Test — GET /v1/news

chưa gọi

1.3 · Chi tiết một bản ghi

GET scope articles

/v1/news/{id}

Một bản ghi theo id, thân bài nguyên văn. Đây là endpoint bạn gọi sau khi nhận một item từ danh sách hoặc stream — id ở đó chính là id ở đây.

Response là chính đối tượng Article ở cấp cao nhất (không bọc trong items), cùng bộ field như §3.

Test — GET /v1/news/{id}

chưa gọi

Mẹo: bấm một chip id ở khối test §1.2 để điền sẵn ô này rồi gọi luôn.

1.4 · Danh mục sản phẩm

GET scope articles

/v1/products

Danh mục sản phẩm hàng hóa phía sau trường products của Article. Mã ở đây chính là mã bạn nhận trong products và là mã dùng cho tham số lọc products của §1.2 — dùng endpoint này để đổi mã thành tên hiển thị (CPE → Đồng) hoặc dựng bộ lọc.

Không có tham số. Trả toàn bộ danh mục trong một response, không phân trang.

Response 200

{
  "items": [
    { "code": "AHD", "name": "Nhôm LME", "productGroup": "Kim loại", "exchanger": "LME" },
    { "code": "ALI", "name": "Nhôm COMEX", "productGroup": "Kim loại", "exchanger": "COMEX" }
  ],
  "count": 50,
  "synced_at": "2026-10-20T03:00:12+00:00"
}
FieldKiểuMô tả
itemsarrayDanh sách sản phẩm, giữ thứ tự của nguồn
items[].codestringMã sản phẩm, duy nhất — giá trị xuất hiện trong products của Article
items[].namestringTên hiển thị tiếng Việt, ví dụ Nhôm LME. Có thể được đổi tên theo thời gian — hiển thị tên tra từ đây thay vì lưu cứng
items[].productGroupstringNhóm hàng, ví dụ Kim loại. Chuỗi rỗng nếu không có
items[].exchangerstringSàn giao dịch, ví dụ LME, COMEX. Chuỗi rỗng nếu không có
countintSố phần tử của items
synced_atstring | nullThời điểm danh mục được cập nhật lần cuối, ISO-8601 UTC. null khi danh mục chưa từng đồng bộ (đang dùng bản dựng sẵn)

Danh mục được hệ thống tự cập nhật mỗi ngày một lần; dữ liệu giá/ký quỹ của nguồn không nằm trong response này. Không cần gọi thường xuyên — cache phía bạn khoảng một ngày là đủ.

Test — GET /v1/products

chưa gọi
2

Realtime stream

Một service riêng, base URL riêng. Kết nối bằng Server-Sent Events.

Xác thực

Cả hai nhận key theo thứ tự: Authorization: Bearer <key> → X-API-Key → ?key= trên query string. Key phải có scope articles (xem §9).

?key= chỉ nên dùng trong trình duyệt. Trình duyệt không cho đặt header trên EventSource không đặt được header, nên query string là lối duy nhất. Nhưng key sẽ nằm trong access log của mọi proxy trên đường đi — kết nối server-to-server hãy dùng header.

2.1 · Server-Sent Events

SSE scope articles

/stream/news

Stream chỉ chở tin phát sinh kể từ lúc bạn kết nối. Không có con trỏ, không có since_id, không đọc bù quá khứ — reconnect là bắt đầu lại từ “bây giờ”. Muốn lấy phần đã bỏ lỡ thì đó là câu hỏi khác, trả lời bằng GET /v1/news (phân trang theo id, lọc theo loại / sản phẩm / khoảng ngày).

Khung sự kiện trên dây

data: {"event":"translated","id":18343,"title":"Tồn kho dầu thô Mỹ giảm 2,1 triệu thùng","content_kind":"article","fetched_at":"2026-08-31T14:02:09+00:00","metadata":{}, … }

: keepalive
DòngÝ nghĩa
data:JSON một dòng — đúng cấu trúc ở §3, thêm trường event bên trong payload
retry:Gợi ý cho trình duyệt chờ bao nhiêu ms trước khi reconnect (3000). Chỉ gửi một lần, lúc mở kết nối
: connectedComment SSE, gửi ngay khi kết nối mở — mốc “từ đây trở đi là realtime”. Im lặng sau đó nghĩa là chưa có tin mới, không phải kết nối hỏng
: keepaliveComment SSE mỗi 20 giây khi không có tin, để proxy không cắt kết nối. Bỏ qua nó
Frame không có dòng event: — dùng onmessage là đủ. Đây là chủ ý: trong SSE, một frame mang event: foo chỉ đi tới addEventListener("foo") và không bao giờ tới onmessage. Ở đây chỉ có đúng một loại frame, nên đặt tên cho nó chẳng được gì mà lại khiến client viết theo cách hiển nhiên nhất không nhận được gì — trong khi DevTools vẫn hiện byte chạy về.

Trường event vẫn nằm bên trong JSON (luôn là "translated") cho đồng bộ với các kênh khác. Nó là hằng số, đừng rẽ nhánh theo nó. Điều đáng nhớ là mỗi bản ghi chỉ tới đúng một lần, và tới khi đã sẵn sàng để hiển thị chứ không phải ngay khoảnh khắc tin xuất hiện — chênh lệch tính bằng giây.
Không có dòng id:, và Last-Event-ID bị bỏ qua. Trong SSE, dòng id: tồn tại đúng để phục vụ cơ chế resume: trình duyệt nhớ nó và gửi lại trong header Last-Event-ID khi tự reconnect. Stream này không resume, nên phát ra id: sẽ là hứa một lần phát lại không bao giờ tới. id của bản ghi vẫn nằm trong payload JSON — ở đó nó là dữ liệu, không phải một lời hứa của giao thức.

Hệ quả thực tế: EventSource vẫn tự kết nối lại như thường, nhưng tin phát ra trong lúc đứt kết nối không được bù. Nếu bạn không được phép bỏ sót tin nào, sau mỗi lần reconnect hãy gọi /v1/news?since_id=<id cuối đã xử lý> để lấp khoảng trống (xem §8).
Vài KB khoảng trắng ở đầu stream là cố ý. Kết nối mở ra bằng một dòng comment dài rồi mới tới dữ liệu. Nhiều proxy giữ response lại tới khi gom đủ một lượng byte mới đẩy đi — với một stream chỉ nhỏ giọt vài trăm byte, điều đó khiến feed trông như chết. Dòng đệm đó đẩy client qua khỏi buffer ngay lập tức. Nó là comment SSE nên client bỏ qua như mọi comment khác.

Ví dụ — JavaScript

const base = 'https://ai-news-socket.sfi.com.vn';
const seen = new Set();                      // at-least-once → tự khử trùng lặp

const es = new EventSource(`${base}/stream/news?key=${KEY}`);

// Frame không có dòng `event:`, nên onmessage nhận mọi tin.
es.onmessage = (e) => {
  const item = JSON.parse(e.data);
  if (seen.has(item.id)) return;
  seen.add(item.id);

  if (item.excerpt) {
    // cần nguyên văn thì gọi REST bằng chính id này
  }
  render(item);
};

es.onerror = () => { /* EventSource tự reconnect — nhưng KHÔNG bù phần đã lỡ */ };

Ví dụ — curl

curl -N -H "Authorization: Bearer <key>" \
  "https://ai-news-socket.sfi.com.vn/stream/news"

Test — SSE /stream/news

Không có tham số nào để nhập: kết nối rồi chờ tin mới. Ngoài giờ giao dịch có thể im lặng vài phút — : keepalive mỗi 20 giây là dấu hiệu kết nối vẫn sống.

chưa kết nối

Khối này gửi key qua ?key= vì EventSource không đặt được header. Không có tin cũ nào được phát lại: mọi dòng bạn thấy đều phát sinh sau khi kết nối mở.

3

Đối tượng Article

Một cấu trúc dùng chung cho tất cả các endpoint và cả stream. Khác biệt giữa các nơi được ghi rõ ở cột cuối.

FieldKiểuNull?Giải thích
idnumber (int)khôngKhóa chính bền vững, tăng dần. Dùng làm khóa lưu trữ phía bạn và làm con trỏ phân trang. Xem §5 về việc id cam kết hơi lệch thứ tự
titlestringkhôngTiêu đề, luôn là tiếng Việt. Hiển thị thẳng
bodystring (HTML)không*Thân bài, luôn là tiếng Việt, đã render sẵn thành HTML — chèn thẳng vào trang, xem §3.1 để biết những thẻ nào có thể xuất hiện. ⚠️ Trong mọi danh sách (/v1/news và stream), bài viết chỉ có khoảng 25 từ đầu — đừng dựa vào con số này, đọc excerpt, xem §4
excerptbooleankhôngtrue khi body mới chỉ là đoạn mở đầu và bạn cần gọi /v1/news/{id} để lấy đủ. Luôn có mặt ở mọi endpoint — đọc đúng một trường này thay vì suy đoán từ endpoint. Chỉ endpoint chi tiết mới luôn false
published_atstringcóThời điểm nguồn công bố, lấy nguyên văn từ nguồn: định dạng lệch nhau giữa các nguồn ("2026-09-01T13:15:14.000Z", "Sun, 30 Aug 2026 05:49:24 GMT") và có thể null. Dùng để hiển thị. Đừng dùng để sắp xếp hay lọc — dùng fetched_at hoặc id
fetched_atstring (ISO-8601 UTC)khôngThời điểm bản ghi sẵn sàng để đọc ở hệ thống này, ví dụ "2026-08-31T09:20:14+00:00". Được đặt khi lấy tin về rồi cập nhật lại khi dịch xong và khi có ảnh bìa — nên nó là mốc “tin này dùng được từ lúc nào”, chứ không phải mốc bắt đầu xử lý. Luôn có và luôn cùng một định dạng: đây là trục thời gian đáng tin, là thứ tự trả về của danh sách, và cũng là cột mà ?from=/?to= so sánh (xem §5)
categoriesarray<string>khôngPhân loại do AI gán, lấy từ tập cố định: Nông sản, Nguyên liệu công nghiệp, Kim loại, Năng lượng, Kinh tế vĩ mô, Khác. Mảng rỗng khi chưa gán được
countrystringkhôngQuốc gia / khu vực do AI gán. "Toàn cầu" khi tin không gắn với nước cụ thể, chuỗi rỗng khi chưa gán được
productsarray<string>khôngMã sản phẩm hàng hóa được nhắc tới, ví dụ ["CPE"]. Mảng rỗng khi tin không nhắc tới sản phẩm nào. Tên và nhóm của từng mã tra ở §1.4
content_kindstringkhôngLoại nội dung, hiện luôn là "article" (bài viết). Field được giữ lại để tương thích — đừng rẽ nhánh theo nó
featured_image_urlstringcóURL tuyệt đối tới ảnh bìa do AI sinh. null khi bài chưa/không có ảnh
metadataobjectkhôngDữ liệu bổ sung do chính nguồn tin đính kèm cho item đó, trả về nguyên dạng. {} với phần lớn bản ghi. ⚠️ Không có key nào được cam kết — mỗi key đều là tùy chọn, phụ thuộc hoàn toàn vào nguồn. Xem §3.2
eventstringkhôngChỉ có trên stream. Trên thực tế luôn là "translated" (xem §2.1)

* body không bao giờ null, nhưng có thể bị cắt — “không null” và “đầy đủ” là hai chuyện khác nhau, hãy đọc excerpt.

3.1 · body — đặc tả HTML và cách render

body là một đoạn HTML, không phải Markdown và không phải văn bản thuần. Chèn thẳng vào trang của bạn là xong — không cần thư viện Markdown, và không phải lo hai bên render ra hai kết quả khác nhau.

Những thẻ có thể xuất hiện

ThẻGhi chú
<p>Đoạn văn — thẻ phổ biến nhất, và là toàn bộ nội dung của một bản xem trước
<h1>…<h6>Tiêu đề mục bên trong bài. Không chứa tiêu đề bài — cái đó nằm ở trường title, đừng hiển thị hai lần
<ul> <ol> <li>Danh sách
<strong> <em>Nhấn mạnh
<a href>Liên kết trong bài, trỏ ra ngoài. Bạn nên tự thêm rel="noopener" / target theo ý mình
<img src>Ảnh minh họa trong bài. src là URL tuyệt đối tới /v1/media/… — tải được không cần key (xem bên dưới)
<blockquote> <code> <pre> <hr>Hiếm, nhưng có thể gặp

Không có <script>, <style>, <iframe>, thuộc tính on*, hay style= nội tuyến — xem ghi chú an toàn bên dưới.

Ảnh trong body và featured_image_url tải được không cần key. Chúng trỏ tới /v1/media/<key>, một route mở. Đó là điều bắt buộc chứ không phải sơ suất: thẻ <img> không gửi được header Authorization, nên nếu route ảnh đòi key thì mọi ảnh trên trang của bạn sẽ vỡ.
Về an toàn. HTML này được sinh từ nội dung thu thập tự động, nên nó được render với chế độ chặn HTML thô: bất kỳ thẻ nào có sẵn trong nội dung nguồn đều bị escape thành chữ (&lt;script&gt;) chứ không đi qua. Dù vậy, nếu chính sách bảo mật của bạn yêu cầu sanitize mọi HTML từ bên ngoài, cứ chạy qua sanitizer của bạn — tập thẻ ở trên rất hẹp nên sẽ không mất gì.

Ví dụ

"body": "<h2>Đồng lên đỉnh ba tuần</h2>\n<p>Giá <strong>đồng</strong> tăng lên mức cao nhất trong ba tuần…</p>\n<ul>\n<li>Tồn kho giảm</li>\n</ul>"

Một bản xem trước (excerpt: true) luôn là đúng một đoạn văn:

"body": "<p>Giá đồng tăng lên mức cao nhất trong ba tuần khi thị trường kỳ vọng nhu cầu từ Trung Quốc phục hồi sau loạt biện pháp...</p>",
"excerpt": true

Đoạn xem trước cố tình không giữ tiêu đề mục, danh sách hay ảnh của bài: cắt HTML theo số từ sẽ để lại thẻ hở, và một đoạn nhử tin thì không cần tới bố cục của bài mà nó nhử.

3.2 · metadata — dữ liệu của nguồn, không phải của chúng tôi

Mọi field khác trong payload là do hệ thống này tạo ra (title, body, categories, fetched_at) hoặc là một giá trị đơn lẻ lấy nguyên từ nguồn (published_at). metadata là ngoại lệ duy nhất: đó là dữ liệu có cấu trúc do chính nguồn tin đính kèm cho item, được trả về đúng như nguồn gửi.

Nó không có schema, và đây là chủ ý chứ không phải thiếu sót. Mỗi nguồn đính kèm một thứ khác nhau, nên chuẩn hóa lại thành một hình dạng chung thì hoặc phải bỏ mất dữ liệu bạn có thể cần, hoặc phải bịa ra một cấu trúc mà không nguồn nào thực sự dùng. Quy tắc khi đọc:

  • Mọi key đều là tùy chọn. Key chỉ xuất hiện khi item thực sự có nó — không có nghĩa là null, mà là không có key.
  • Phần lớn bản ghi trả về {}. Đa số nguồn không đính kèm gì cả. Đừng coi {} là lỗi hay là "chưa xử lý xong".
  • Đừng rẽ nhánh logic theo việc object có rỗng hay không, và đừng coi bất kỳ key nào là điều kiện để hiển thị tin. Tin vẫn đầy đủ khi metadata rỗng.
  • Key mới có thể xuất hiện bất cứ lúc nào mà không đổi phiên bản, vì chỉ cần nguồn gửi thêm một field là nó có ở đây.

Hiện chưa có key nào được cam kết. Nếu một nguồn đính kèm dữ liệu, bạn sẽ thấy nó ở đây nguyên dạng — hãy đọc theo kiểu “có thì dùng, không có thì bỏ qua”.

metadata có mặt ở mọi nơi trả về item — danh sách, chi tiết và SSE đều dựng từ cùng một định nghĩa payload, nên object bạn nhận trên stream giống hệt object trên REST. Riêng bản xem trước không hề ảnh hưởng tới nó: body bị cắt còn metadata luôn đầy đủ.
Đừng giả định danh sách field là cố định. Field mới có thể được thêm vào mà không đổi phiên bản. Parser của bạn nên bỏ qua field lạ thay vì lỗi.
4

Bản xem trước (excerpt)

Mục này giải thích phần dễ gây khó chịu nhất: vì sao thân bài bị cắt, và xử lý thế nào cho đúng.

Vì sao cắt

Danh sách sinh ra để báo “có tin này, đây là id” càng rẻ càng tốt. Nếu đẩy nguyên thân bài tới mọi subscriber ở mọi sự kiện thì stream sẽ nặng hơn hàng trăm lần so với thứ nó được thiết kế để chở, và một trang 100 bản ghi cũng nặng tương tự so với việc nó chỉ cần trả lời “có gì”. Trong khi đó phần lớn consumer chỉ hiển thị tiêu đề trong danh sách rồi mới tải nội dung khi người dùng bấm vào.

Con số 25 là mặc định, không phải cam kết. Nó là một tham số vận hành và có thể được điều chỉnh mà không đổi phiên bản API, nên đừng viết code dựa vào độ dài bản xem trước — excerpt là trường duy nhất nói cho bạn biết thân bài đã đủ hay chưa.
Bài viết ngắn hơn mức cắt thì không bị cắt và trả về excerpt: false. Vậy nên hãy kiểm tra cờ đó thay vì tự đoán theo độ dài — bạn sẽ không gọi thừa một request chi tiết cho thứ mình đã có đủ.
// Cách xử lý đúng, phía consumer
if (item.excerpt) {
    const full = await fetch(`${REST}/v1/news/${item.id}`, { headers });
    item.body = (await full.json()).body;
}
5

Bộ lọc và phân trang

Không có page, không có offset, không có total. Phân trang bằng con trỏ id, chạy được cả hai chiều, và mỗi response trả sẵn con trỏ cho cả hai: next_since_id (bản ghi mới nhất trang này) và next_before_id (cũ nhất).

Con trỏ chỉ có trên REST. Stream không phân trang, không nhận con trỏ và không đọc lại quá khứ — nó chỉ chở tin phát sinh kể từ lúc bạn kết nối. Mọi câu hỏi về quá khứ, kể cả “lấp phần vừa bỏ lỡ”, đều trả lời bằng /v1/news.

Đọc bù về phía trước — since_id

Câu hỏi “có gì mới hơn thứ tôi đã xử lý”. Đây cũng là cách lấp khoảng trống sau khi stream đứt.

let cursor = 0;
for (;;) {
  const r = await get(`/v1/news?since_id=${cursor}&order=asc&limit=100`);
  if (r.count === 0) break;          // hết dữ liệu
  for (const item of r.items) handle(item);
  cursor = r.next_since_id;          // luôn tiến, không bao giờ lùi
  if (r.count < 100) break;          // trang cuối
}

Lật về quá khứ — before_id

Câu hỏi “cho tôi xem tiếp phía dưới”. Dùng với /v1/news (mặc định đã giảm dần) — đây là kiểu cuộn vô hạn của một trang tin.

let before = 0;                      // 0 = bắt đầu từ mới nhất
for (;;) {
  const r = await get(`/v1/news?before_id=${before}&limit=20`);
  if (r.count === 0) break;
  for (const item of r.items) render(item);
  before = r.next_before_id;         // luôn lùi dần về quá khứ
  if (r.count < 20) break;           // đã tới bản ghi cũ nhất
}

Lọc

Bộ lọc chỉ có trên /v1/news — stream không lọc được gì. Các bộ lọc kết hợp với nhau bằng AND. Đặc tả đầy đủ ở §1.2. Những điểm dễ nhầm:

  • products=CPE,FEF là OR — bài nhắc tới bất kỳ mã nào trong danh sách, không phải bài nhắc tới tất cả.
  • categories=kim loai,nang luong cũng là OR, không phân biệt hoa thường và dấu tiếng Việt, nhưng phải khớp trọn tên nhóm — không có tìm theo một phần chữ.
  • from/to tính trên fetched_at (thời điểm hệ thống thu thập), không phải published_at — xem ghi chú ở §1.2. Dạng ngày thì to bao trọn ngày đó.
  • Bộ lọc không đổi ý nghĩa con trỏ: count < limit vẫn là dấu hiệu hết dữ liệu, nhưng là hết dữ liệu khớp bộ lọc, không phải hết toàn bộ kho.
Giao hàng là at-least-once, không phải exactly-once. Các bản ghi được xử lý song song nên sẵn sàng hơi lệch thứ tự so với lúc lấy tin về. Hệ quả thực tế:
  • Luôn truyền lại nguyên văn next_since_id mà response trả về, đừng tự tính con trỏ từ các id đã nhận (xem cảnh báo ở §1.2).
  • Khử trùng lặp theo id ở phía bạn. Đây là bắt buộc, không phải tối ưu.
  • Đừng coi dãy id là liên tục, và đừng dùng id để sắp xếp — sắp theo fetched_at. Có khoảng trống trong dãy id là bình thường.
6

Bảng mã lỗi

HTTPKhi nàoBodyNên làm gì
400 Gửi ?translated=0 — bản ghi chưa dịch không bao giờ được phục vụ, không tham số nào xin được {"error": "translated=0 is no longer supported…"} Bỏ tham số đó đi. API trả 400 thay vì âm thầm phục vụ một tập kết quả khác với thứ bạn yêu cầu
400 ?from= / ?to= không đọc được thành ngày {"error": "from must be a date (2026-09-01) or an ISO-8601 timestamp."} Dùng YYYY-MM-DD hoặc timestamp ISO-8601. Ngày sai lịch (2026-13-01) cũng vào nhóm này — API không âm thầm bỏ qua bộ lọc hỏng
401 Thiếu key, key sai, key đã thu hồi, hoặc key không có scope mà endpoint đòi {"error": "Unauthorized…", "scope": "articles"} Trường scope cho biết endpoint cần scope nào — đối chiếu với key bạn được cấp. Đừng retry
404 Không có bản ghi id đó, hoặc có nhưng chưa sẵn sàng để phục vụ {"error": "Not found."} Hai nguyên nhân được gộp có chủ ý. Nếu id lấy từ feed, xem §7
5xx Service đang deploy hoặc gặp sự cố Không đảm bảo là JSON Retry có backoff. Con trỏ since_id của bạn vẫn hợp lệ, không mất gì
Không có rate limit công bố, nhưng cũng đừng poll dày. Nếu bạn cần độ trễ thấp thì dùng stream — đó chính là thứ nó sinh ra để giải quyết; poll /v1/news mỗi vài giây chỉ tốn tài nguyên hai bên mà vẫn chậm hơn.
7

Trường hợp biên

Tình huốngHành vi
limit=9999Không lỗi. Bị kẹp về 100. limit=0 hay số âm bị kẹp về 1; chữ cái bị bỏ qua, dùng mặc định 100
since_id mới hơn mọi bản ghi hiện có200 với items: [], count: 0, và next_since_id bằng đúng giá trị bạn gửi
since_id trỏ vào bản ghi đã bị dọn theo hạn lưu trữKhông lỗi. Server không còn bản ghi đó để định vị nên quay về so sánh id thuần — kết quả có thể lệch một chút ở vùng giáp ranh, cách chắc chắn nhất là khử trùng lặp theo id như trên
products= mã không tồn tạiKhông lỗi. Đơn giản là không khớp bản ghi nào — items: []. Danh sách mã hợp lệ lấy từ GET /v1/products; mã trong response chính là mã dùng để lọc
Khoảng ngày rỗng hoặc đảo ngược (from > to)Không lỗi, trả items: []. API không đoán ý bạn muốn đảo lại hai đầu
Vừa since_id vừa before_idHợp lệ — hai điều kiện AND với nhau, cho ra một lát cắt since_id < id < before_id
order là giá trị lạBỏ qua, giữ mặc định desc. Chỉ asc và desc có tác dụng
Nhận id từ danh sách hoặc stream nhưng /v1/news/{id} trả 404Item đó không còn được phục vụ nữa — dữ liệu cũ đã được dọn, hoặc nó vừa rơi khỏi tập nội dung công khai. Bỏ qua và đi tiếp, đừng retry vòng lặp
Có khoảng trống trong dãy idBình thường. Không phải bản ghi nào cũng được phục vụ, nhưng id thì vẫn bị chiếm
Cùng một tin xuất hiện hai lầnCó thể xảy ra khi reconnect (giao hàng at-least-once). Khử trùng lặp theo id
Hai nguồn đăng cùng một tinLà hai bản ghi khác nhau, khác id. Hệ thống không gộp tin trùng nội dung giữa các nguồn. Muốn gộp thì đối chiếu tiêu đề ở phía bạn
Stream im lặng nhiều phútBình thường ngoài giờ giao dịch. Bạn vẫn nhận : keepalive (SSE) mỗi 20 giây — đó mới là dấu hiệu kết nối còn sống
Mất kết nối (vài giây hay vài giờ)Stream không phát bù phần đã lỡ, dù ngắn hay dài. Nối lại là nhận tiếp từ “bây giờ”. Muốn không sót thì sau khi nối lại, gọi /v1/news?since_id=<id cuối đã xử lý>&order=asc phân trang cho tới khi bắt kịp
Gửi since_id cho streamBị bỏ qua — kết nối vẫn mở và chạy bình thường, chỉ là không có tin cũ nào được phát lại. Con trỏ là chuyện của /v1/news (xem §5)
published_at null hoặc định dạng lạLấy nguyên văn từ nguồn nên có thể như vậy. Cần một trục thời gian chắc chắn thì dùng fetched_at (luôn có, luôn ISO-8601 UTC) hoặc id
8

Mẫu tích hợp khuyến nghị

  1. Nạp lần đầu — phân trang /v1/news?order=asc&since_id=0&limit=100 cho tới khi count < limit. Lưu id lớn nhất làm con trỏ. (Chỉ cần một quãng gần đây thì thêm from= thay vì kéo lại từ đầu.)
  2. Chạy thường xuyên — mở stream (SSE). Stream không nhận con trỏ: nó chở tin phát sinh kể từ lúc kết nối. Cập nhật con trỏ của bạn sau mỗi item đã xử lý xong, không phải lúc vừa nhận.
  3. Với item excerpt: true — nếu bạn cần nguyên văn ngay, gọi /v1/news/{id}. Nếu chỉ hiển thị danh sách, cứ để preview và chỉ tải chi tiết khi người dùng bấm vào.
  4. Khi mất kết nối — reconnect có backoff, rồi lấp khoảng trống bằng REST: /v1/news?order=asc&since_id=<con trỏ − 50> cho tới khi bắt kịp. Trừ 50 là để bù phần id cấp hơi lệch thứ tự; phần trùng thì khử theo id. Bước này bắt buộc — stream không tự phát bù.
  5. Trang duyệt / trang tìm kiếm phía bạn — dùng /v1/news với before_id để cuộn, kèm products, categories, from/to theo bộ lọc người dùng chọn. Đừng tự tải hết về rồi lọc ở client: bộ lọc chạy ở database rẻ hơn nhiều lần.
  6. Lưu nội dung phía bạn. API là nguồn cấp, không phải kho lưu trữ: dữ liệu cũ sẽ bị dọn, và một item hôm nay lấy được thì ngày mai chưa chắc còn.
9

Bảo mật và vận hành

  • Key được cấp theo scope. Scope articles mở GET /v1/news, GET /v1/news/{id} và stream. Thiếu scope thì trả 401 kèm tên scope endpoint đó cần.
  • Hệ thống chỉ lưu hash SHA-256 của key, nên bản rõ chỉ hiện đúng một lần lúc tạo. Mất thì phải cấp key mới, không khôi phục được.
  • Ưu tiên header Authorization: Bearer hoặc X-API-Key. REST không nhận key qua query string — đúng để nó không rơi vào access log. Stream có nhận ?key=, nhưng chỉ vì trình duyệt không đặt được header trên SSE.
  • Đừng nhúng key vào frontend công khai. Trang này nhận key chỉ để test thủ công và lưu trong localStorage của chính trình duyệt bạn. Ứng dụng thật nên gọi qua backend của mình.
  • CORS mở cho mọi origin trên /v1/* và trên stream. Điều đó không nới lỏng bảo mật: mọi request vẫn phải có key hợp lệ, và một key thì gọi bằng curl cũng được.
  • Chỉ đọc. Không có endpoint ghi, không có endpoint xóa. Không thao tác nào từ phía bạn thay đổi được dữ liệu.
  • Hai service tách rời. REST và stream chạy độc lập — một cái chết không kéo cái kia theo, và hàng nghìn kết nối stream cũng không làm chậm REST. Health check riêng cho từng cái.