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.vnRESThttps://ai-news-socket.sfi.com.vnSTREAMTổng quan
| Base URL — REST | https://ai-news-api.sfi.com.vn |
| Base URL — Stream | https://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ạng | JSON UTF-8 (có dấu tiếng Việt). Stream: text/event-stream |
| Phương thức | Chỉ GET (và OPTIONS cho preflight CORS) |
| Xác thực | Authorization: Bearer <key> hoặc X-API-Key: <key> |
| CORS | Mở 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"
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
| Endpoint | Service | Mô 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 |
| WS /ws/news | Stream | WebSocket — cùng dữ liệu, dùng khi client đã có sẵn hạ tầng WS | 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.
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
/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
1.2 · Danh sách tin
/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ểu | Mặc định | Giải thích |
|---|---|---|---|
| products | string | không lọc | Danh 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ì |
| categories | string | không lọc | Lọ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ì |
| from | string | khô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 |
| to | string | không giới hạn | Cuố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_id | integer | 0 | Con 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_id | integer | 0 | Lậ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 |
| order | string | desc | desc 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) |
| limit | integer | 100 | Số 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 |
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
| Field | Kiểu | Giải thích |
|---|---|---|
| items | array | Mả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_id | number | id 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_id | number | id 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 |
| count | number | Số phần tử trong items. count < limit nghĩa là bạn đã đọc hết dữ liệu khớp bộ lọ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.
/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
1.3 · Chi tiết một bản ghi
/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}
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
/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"
}
| Field | Kiểu | Mô tả |
|---|---|---|
| items | array | Danh sách sản phẩm, giữ thứ tự của nguồn |
| items[].code | string | Mã sản phẩm, duy nhất — giá trị xuất hiện trong products của Article |
| items[].name | string | Tê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[].productGroup | string | Nhóm hàng, ví dụ Kim loại. Chuỗi rỗng nếu không có |
| items[].exchanger | string | Sàn giao dịch, ví dụ LME, COMEX. Chuỗi rỗng nếu không có |
| count | int | Số phần tử của items |
| synced_at | string | null | Thờ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
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
/stream/news
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 |
| : connected | Comment 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 |
| : keepalive | Comment SSE mỗi 20 giây khi không có tin, để proxy không cắt kết nối. Bỏ qua nó |
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.
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í 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.
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ở.
Đố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.
| Field | Kiểu | Null? | Giải thích |
|---|---|---|---|
| id | number (int) | không | Khó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ự |
| title | string | không | Tiêu đề, luôn là tiếng Việt. Hiển thị thẳng |
| body | string (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 |
| excerpt | boolean | không | true 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_at | string | có | 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_at | string (ISO-8601 UTC) | không | Thờ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) |
| categories | array<string> | không | Phâ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 |
| country | string | không | Quố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 |
| products | array<string> | không | Mã 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_kind | string | không | Loạ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_url | string | có | URL tuyệt đối tới ảnh bìa do AI sinh. null khi bài chưa/không có ảnh |
| metadata | object | không | Dữ 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 |
| event | string | không | Chỉ 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.
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ỡ.
<script>) 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
metadatarỗ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 đủ.
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.
excerpt là trường duy nhất nói cho bạn biết
thân bài đã đủ hay chưa.
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;
}
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).
/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,FEFlà 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 luongcũ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/totính trênfetched_at(thời điểm hệ thống thu thập), không phảipublished_at— xem ghi chú ở §1.2. Dạng ngày thìtobao trọn ngày đó.- Bộ lọc không đổi ý nghĩa con trỏ:
count < limitvẫ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.
- Luôn truyền lại nguyên văn
next_since_idmà response trả về, đừng tự tính con trỏ từ cácidđã 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 theofetched_at. Có khoảng trống trong dãy id là bình thường.
Bảng mã lỗi
| HTTP | Khi nào | Body | Nê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ì |
| 4401 (WS close) | Tương đương 401, nhưng trên WebSocket | — | Đừng reconnect cho tới khi sửa key |
| 1013 (WS close) | Consumer xử lý quá chậm, bị bỏ lại quá xa | — | Reconnect ngay (nhận tiếp từ “bây giờ”), rồi lấp khoảng trống bằng /v1/news?since_id=…; xử lý nặng thì đưa ra khỏi callback |
/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.
Trường hợp biên
| Tình huống | Hành vi |
|---|---|
limit=9999 | Khô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ại | Khô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_id | Hợ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ả 404 | Item đó 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 id | Bì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ần | Có 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 tin | Là 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út | Bì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 stream | Bị 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 |
Mẫu tích hợp khuyến nghị
- Nạp lần đầu — phân trang
/v1/news?order=asc&since_id=0&limit=100cho tới khicount < limit. Lưuidlớn nhất làm con trỏ. (Chỉ cần một quãng gần đây thì thêmfrom=thay vì kéo lại từ đầu.) - 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.
- 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. - 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ử theoid. Bước này bắt buộc — stream không tự phát bù. - Trang duyệt / trang tìm kiếm phía bạn — dùng
/v1/newsvớibefore_idđể cuộn, kèmproducts,categories,from/totheo 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. - 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.
Bảo mật và vận hành
- Key được cấp theo scope.
Scope
articlesmởGET /v1/news,GET /v1/news/{id}và stream. Thiếu scope thì trả401kè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: BearerhoặcX-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
localStoragecủ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ằngcurlcũ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.
Tin nóng — API tin nhanh & báo cáo hàng hóa
API chỉ đọc trả về hai loại dữ liệu mà hệ thống bot đã đăng lên Telegram:
tin nhanh (tin tài chính quốc tế đã dịch sang tiếng Việt) và báo cáo
(USDA / EIA / CFTC / UNICA… dưới dạng ảnh biểu bảng). Xác thực bằng header X-API-Key.
Mỗi endpoint có khối test gọi trực tiếp từ trình duyệt.
http://42.96.44.143:8080RESTTổng quan
| Dữ liệu | Mô tả | Tần suất phát sinh |
|---|---|---|
| Tin nhanh | Tin tài chính quốc tế đã dịch sang tiếng Việt, phân loại theo nhóm hàng và mã sản phẩm SFI | Liên tục; trung bình 500 tin/ngày, cao điểm 900 |
| Báo cáo | Báo cáo USDA / EIA / CFTC / UNICA / Brazil / Argentina dưới dạng ảnh biểu bảng và biểu đồ | Theo lịch công bố, khoảng 10–15 báo cáo/tuần |
Dữ liệu được ghi ngay tại thời điểm bot đăng lên Telegram, không có độ trễ xử lý.
| Base URL | http://42.96.44.143:8080 |
| Xác thực | Header X-API-Key: <khoá> — khoá cấp riêng, gửi kèm qua kênh khác. Mọi đường dẫn đều cần khoá, trừ /api/health |
| Giới hạn tần suất | Tối đa 5 lượt/giây cho mỗi khoá. Vượt sẽ nhận 429, chờ 1 giây là gọi lại được. Giới hạn này nhằm phòng lỗi vòng lặp, không cản trở việc gọi định kỳ bình thường |
| Phương thức | Chỉ GET |
curl -H "X-API-Key: <khoá>" \
"http://42.96.44.143:8080/api/news?products=CPE,ZCE&order=desc&limit=5"
/api/v1/flashnews/news phía ứng
dụng): lớp đó cần chuyển tiếp nguyên các tham số ở §2 và trả nguyên các
trường mới (products, categories, next_before,
has_more). Trường không được khai báo ở lớp trung gian sẽ ra null phía ứng dụng
dù API trả đủ.
⚡ Cấu hình test — dùng cho mọi khối test của tab Tin nóng
curl
như các ví dụ trong trang.
Danh mục endpoint
| Endpoint | Mô tả | Xác thực |
|---|---|---|
| GET /api/news | Tin nhanh — lấy tin mới (cũ trước), cuộn về tin cũ (mới trước), lọc theo sản phẩm / nhóm / khoảng ngày | X-API-Key |
| GET /api/reports | Danh sách báo cáo (mỗi báo cáo kèm tên các file ảnh) | X-API-Key |
| GET /api/reports/image/{tên_file} | Tải một ảnh báo cáo (PNG) | X-API-Key |
| GET /api/health | Kiểm tra tình trạng, dùng cho hệ thống giám sát | KHÔNG CẦN KEY |
Cách lấy dữ liệu
Áp dụng cho /api/news (cả ba cách) và /api/reports (chỉ cách 1.1).
1.1 · Lấy tin mới (cũ trước — mặc định)
Truyền since là id của bản ghi cuối cùng đã lấy, hệ thống trả về những bản
ghi mới hơn.
# Lần đầu
GET /api/news?since=0 -> { "items": [...], "next_since": 1483 }
# Lần tiếp (chưa có tin mới)
GET /api/news?since=1483 -> { "items": [], "next_since": 1483 }
# Lần tiếp (có tin mới)
GET /api/news?since=1483 -> { "items": [...], "next_since": 1490 }
Lưu next_since lại, truyền vào lần gọi sau. Không bao giờ trả trùng, không bao giờ
bỏ sót. Khi không có dữ liệu mới, items rỗng và next_since giữ nguyên —
phản hồi rất nhẹ.
1.2 · Cuộn về tin cũ (mới trước) v1.4
Dùng cho màn danh sách cuộn vô hạn: lần đầu lấy tin mới nhất, cuộn xuống thì lấy tiếp tin cũ hơn.
Chỉ áp dụng cho /api/news.
# Lần đầu
GET /api/news?order=desc&limit=20
-> { "items": [745, 744, …, 726], "next_before": 726, "has_more": true }
# Cuộn tiếp
GET /api/news?order=desc&limit=20&before=726
-> { "items": [725, …, 706], "next_before": 706, … }
Dừng khi has_more là false.
1.3 · Quy tắc con trỏ khi có bộ lọc v1.4
- Luôn truyền lại nguyên
next_since/next_beforemà server trả về. Không tự tính từidlớn nhất / nhỏ nhất trongitems. Khi lọc, server có thể duyệt qua nhiều tin không khớp; con trỏ trả về đã tính cả các tin đó nên lần gọi sau không duyệt lại, không bỏ sót. count < limitkhông có nghĩa là hết dữ liệu khi có bộ lọc. Dấu hiệu hết dữ liệu làhas_more: false.- Mỗi lượt gọi có bộ lọc duyệt tối đa 20.000 tin. Nếu chưa đủ
limittin khớp, phản hồi trả số tin đã tìm được kèmhas_more: true— gọi tiếp bằng con trỏ để lấy phần còn lại.
Tin nhanh — GET /api/news
/api/news
2.1 · Tham số
| Tên | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| since | số | 0 | Chỉ lấy tin có id lớn hơn giá trị này |
| limit | số | 100 | Số tin tối đa mỗi lần, từ 1 đến 500 |
| order | chuỗi | asc | v1.4 asc = cũ trước (như cũ) · desc = mới trước. Giá trị lạ bị bỏ qua, giữ asc |
| before | số | 0 | v1.4 Chỉ lấy tin có id nhỏ hơn giá trị này. 0 = không giới hạn. Dùng với order=desc để cuộn về tin cũ |
| products | chuỗi | không lọc | v1.4 Mã sản phẩm SFI, cách nhau dấu phẩy, ví dụ CPE,ZCE. Khớp bất kỳ mã nào (OR). Không phân biệt hoa thường. Mã lạ không lỗi, chỉ không khớp. Danh sách mã ở §2.6 |
| categories | chuỗi | không lọc | v1.4 Nhóm, cách nhau dấu phẩy, ví dụ Kim loại,Năng lượng. Khớp bất kỳ nhóm nào (OR). Không phân biệt hoa thường và không phân biệt dấu (kim loai = KIM LOẠI = Kim loại). Phải khớp trọn tên nhóm (kim không khớp Kim loại). Nhớ URL-encode dấu cách |
| from | chuỗi | không giới hạn | v1.4 Từ thời điểm, tính trên posted_at. Dạng ngày 2026-10-01 = từ 00:00 giờ Việt Nam ngày đó. Nhận cả timestamp ISO-8601 đầy đủ. Sai định dạng trả 400 |
| to | chuỗi | không giới hạn | v1.4 Đến thời điểm. Dạng ngày lấy trọn ngày đó (to=2026-10-31 gồm cả 31/10). Timestamp đầy đủ thì mốc này là loại trừ |
Các bộ lọc kết hợp với nhau theo AND. Không truyền tham số mới = kết quả như các bản trước.
Ví dụ
# Tin mới như cũ (gọi 5 giây/lần)
GET /api/news?since=1483
# 20 tin đồng mới nhất
GET /api/news?products=CPE&order=desc&limit=20
# Tin cà phê (Arabica hoặc Robusta) trong tháng 10
GET /api/news?products=KCE,LRC&from=2026-10-01&to=2026-10-31&order=desc
# Tin nhóm Kim loại hoặc Năng lượng, gõ không dấu
GET /api/news?categories=kim%20loai,nang%20luong&order=desc&limit=20
# Gọi định kỳ lấy tin mới chỉ của nhóm Nông sản
GET /api/news?categories=nong%20san&since=1483
2.2 · Phản hồi
{
"items": [
{
"id": 1483,
"posted_at": "2026-10-13T16:31:48+07:00",
"source": "mkt",
"category": "Nông sản",
"level": "",
"important": false,
"speaker": "",
"title": "Argentina bỏ thuế xuất khẩu ngũ cốc",
"body": "Mức thuế đậu tương về 0% cho tới ngày 31/10.",
"text_vi": "【Argentina bỏ thuế xuất khẩu ngũ cốc】Mức thuế đậu tương về 0% cho tới 31/10.",
"text_en": "Argentina suspends export taxes on soybeans and corn until 31/10.",
"tags": [
{ "symbol": "ZS", "name_vi": "Đậu tương", "kind": "hang_hoa",
"impact": "bearish", "impact_vi": "Tiêu cực" },
{ "symbol": "ZC", "name_vi": "Ngô", "kind": "hang_hoa",
"impact": "bearish", "impact_vi": "Tiêu cực" }
],
"msg_id": 1800,
"products": ["ZSE", "ZCE"],
"categories": ["Nông sản"]
}
],
"count": 1,
"next_since": 1483,
"next_before": 1483,
"has_more": false
}
Trường cấp ngoài
| Trường | Mô tả |
|---|---|
| items | Mảng tin, theo thứ tự order yêu cầu |
| count | Số tin trong items |
| next_since | Truyền lại vào since để lấy tin mới hơn. Khi items rỗng = đúng giá trị since đã gửi |
| next_before | v1.4 Truyền lại vào before để cuộn tiếp về tin cũ |
| has_more | v1.4 false = đã hết dữ liệu (khớp bộ lọc). true = có thể còn, gọi tiếp bằng con trỏ |
Giải thích các trường của tin
| Trường | Mô tả |
|---|---|
| id | Số thứ tự tăng dần, khoá chính. Dùng cho since / before |
| posted_at | Thời điểm đăng, giờ Việt Nam, chuẩn ISO 8601 |
| source | mkt (MKTNews), fj (FinancialJuice), rss (nguồn RSS hàng hóa) |
| category | Nhóm do bot gán, giữ nguyên như bản trước. Một trong: Việt Nam · Nông sản · Nguyên liệu công nghiệp · Năng lượng · Kim loại · Vĩ mô · Địa chính trị · Hàng hóa. Nhãn Hàng hóa dùng cho tin chạm từ hai nhóm hàng trở lên. Để lọc, dùng categories (§2.6) |
| level | red / warn / yellow / rỗng — xem §2.3 |
| important | true khi level là red |
| speaker | Người phát biểu, nếu tin là trích dẫn. Rỗng nếu không phải |
| title | Tiêu đề tin, nếu tin dài và tách được tiêu đề. Rỗng với tin ngắn và tin gộp (§2.5). Nội dung tiêu đề cũng nằm trong text_vi, bên trong cặp 【 】 |
| body | Phần nội dung sau tiêu đề. Rỗng khi title rỗng. title + body luôn đi theo cặp: hoặc cả hai có, hoặc cả hai rỗng |
| text_vi | Nội dung tiếng Việt — đây là nội dung chính |
| text_en | Bản gốc tiếng Anh, để đối chiếu khi cần |
| tags | Mảng thẻ tác động theo mã hàng hóa / chỉ số / tiền tệ do MKTNews gắn. Có thể là mảng rỗng. Xem §2.4 |
| msg_id | Id tin nhắn trong nhóm Telegram, dùng để truy ngược. Không duy nhất — xem §2.5 |
| products | v1.4 Mảng mã sản phẩm SFI mà tin nhắc tới. Luôn là mảng, không bao giờ null; [] khi tin không nhắc mặt hàng nào trong danh sách — xem §2.6 |
| categories | v1.4 Mảng nhóm theo bộ AI News Feed. Luôn có ít nhất 1 phần tử — xem §2.6 |
null thay cho chuỗi rỗng. Các trường chuỗi
(source, category, level, speaker,
text_en, title, body) có thể là null; phía gọi nên
xử lý được cả hai trường hợp. products, categories, tags thì
luôn là mảng.
Test — GET /api/news
2.3 · Mức độ quan trọng (level)
| level | Trên Telegram | Ý nghĩa | Ví dụ |
|---|---|---|---|
| red | 🔴 | Rất quan trọng: số liệu kinh tế chính, quyết định lãi suất, sự kiện lớn | Fed giữ lãi suất; tấn công cơ sở dầu khí |
| warn | ⚠️ | Tin nóng chưa xác nhận / tuyên bố một phía, mức bất định cao | “Theo nguồn tin…”, một bên tham chiến tuyên bố… |
| yellow | 🟡 | Số liệu kinh tế của Mỹ hoặc Trung Quốc không thuộc nhóm rất quan trọng | Doanh số bán lẻ Redbook của Mỹ |
| (rỗng) | (không biểu tượng) | Tin thông thường | — |
Thứ tự mức độ: red > warn > yellow > rỗng.
2.4 · Thẻ tác động (tags)
Mỗi tin có thể kèm một hoặc nhiều thẻ, cho biết tin đó liên quan tới mã hàng hóa, chỉ số, tiền tệ nào và hướng tác động tới giá. Thẻ do MKTNews gắn tự động tại nguồn; hệ thống chỉ bổ sung tên tiếng Việt và phân nhóm để tiện hiển thị.
| Trường con | Mô tả |
|---|---|
| symbol | Mã nguyên gốc do MKTNews gửi. Ví dụ: ZS, Copper, XAUUSD, S&P500, NVDA.O |
| name_vi | Tên tiếng Việt của mã. Rỗng nghĩa là mã chưa có trong bảng dịch (cổ phiếu riêng lẻ và mã mới) — khi rỗng, hiển thị symbol thay thế |
| kind | Nhóm của mã: hang_hoa · chi_so · tien_te · tien_so · co_phieu · khac |
| impact | Hướng tác động nguyên gốc: bullish · bearish · mixed · none. Giữ lại để đối chiếu khi cần truy vết |
| impact_vi | Nhãn tiếng Việt tương ứng, dùng để hiển thị trực tiếp. Xem bảng dưới |
Nhãn hiển thị của hướng tác động
| impact | impact_vi |
|---|---|
| bullish | Tích cực |
| bearish | Tiêu cực |
| mixed | Trái chiều |
| none | (rỗng) — cũng áp dụng khi impact là chuỗi rỗng. Hiển thị tên mã, không hiện nhãn tác động |
Nhãn tiếng Việt do API sinh ra, không phải do ứng dụng tự dịch. Nếu bên nghiệp vụ muốn đổi cách gọi,
bên vận hành sửa trên máy chủ và ứng dụng tự đổi theo — không cần phát hành bản mới. Vì vậy đề nghị
hiển thị trực tiếp giá trị impact_vi, không hard-code bảng dịch trong ứng dụng.
bullish / bearish là hướng GIÁ do MKTNews chấm tự động — KHÔNG phải
nhận định hay khuyến nghị của SFI Research, và không có nghĩa tin tốt hay tin xấu.
Ví dụ: tin Argentina bỏ thuế xuất khẩu ngũ cốc được chấm bearish (giá đậu tương giảm),
nhưng với doanh nghiệp thức ăn chăn nuôi đang nhập khẩu thì đó là tin có lợi. Đề nghị không diễn
giải thêm, không dùng thẻ này làm cơ sở cho bất kỳ gợi ý đầu tư nào trên ứng dụng.
mkt mới có thẻ, và chỉ một phần nhỏ số
tin đó. Tin từ fj và rss không bao giờ có thẻ. Vì vậy tags
là mảng rỗng ở phần lớn tin — đây là trạng thái bình thường, ứng dụng phải hiển thị tin đầy đủ khi
tags rỗng. Để lọc theo mặt hàng, dùng products (§2.6), không dùng
tags.
2.5 · Tin gộp và tin nối thêm
Trên Telegram, bot có hai cách đưa nhiều ý vào cùng một tin nhắn. API ghi cả hai, mỗi trường hợp theo một cách:
| Trường hợp | Trên Telegram | Trong API |
|---|---|---|
| Tin gộp — nhiều tin cùng chủ đề đến cùng lúc (vd. báo cáo tháng OPEC theo từng nước, nhiều số liệu kinh tế cùng giờ) | Một tin nhắn dạng khối, mỗi ý một gạch đầu dòng | Một dòng. Toàn bộ khối nằm trong text_vi; title, body rỗng; text_en là các bản gốc nối bằng ␞ |
| Tin nối thêm — cùng một người / cùng một câu chuyện có thêm ý mới sau đó | Bot sửa tin nhắn cũ, thêm ý mới vào | Thêm một dòng mới (id mới), text_vi chỉ chứa ý mới, cùng msg_id với dòng cũ |
Hệ quả cho ứng dụng:
msg_idkhông duy nhất: nhiều dòng có thể cùngmsg_id. Không dùngmsg_idlàm khoá chính. Khoá chính làid.- Muốn hiển thị giống Telegram (gom theo tin nhắn) thì nhóm các dòng cùng
msg_id, sắp theoidtăng dần. Muốn hiển thị dạng dòng tin thì dùng từng dòng như bình thường. msg_idcó thểnull(hiếm).
2.6 · Mã sản phẩm (products) và nhóm (categories) v1.4
Hai trường này giúp tab Tin nóng lọc giống các tab AI News Feed. Cả hai do API tính khi trả dữ liệu, nên tin cũ cũng có.
products — mã sản phẩm SFI
Hệ thống đọc nội dung tin (bản gốc tiếng Anh) và thẻ MKTNews để gắn mã. Chỉ gắn mã chính của
mỗi mặt hàng — không gắn các bản mini/micro/nano hay mã LME trùng tên (vd tin đồng chỉ gắn
CPE, không gắn MQC, MHG, CAD…). Một tin có thể có
nhiều mã.
| Mã | Sản phẩm | Nhóm |
|---|---|---|
| ZSE | Đậu tương | Nông sản |
| ZLE | Dầu đậu tương | Nông sản |
| ZME | Khô đậu tương | Nông sản |
| ZCE | Ngô | Nông sản |
| ZWA | Lúa mì | Nông sản |
| KWE | Lúa mì Kansas | Nông sản |
| KCE | Cà phê Arabica | Nguyên liệu công nghiệp |
| LRC | Cà phê Robusta | Nguyên liệu công nghiệp |
| SBE | Đường 11 | Nguyên liệu công nghiệp |
| QW | Đường trắng | Nguyên liệu công nghiệp |
| CCE | Ca cao | Nguyên liệu công nghiệp |
| CTE | Bông sợi | Nguyên liệu công nghiệp |
| MPO | Dầu cọ thô | Nguyên liệu công nghiệp |
| ZFT | Cao su TSR20 | Nguyên liệu công nghiệp |
| TRU | Cao su RSS3 | Nguyên liệu công nghiệp |
| CPE | Đồng | Kim loại |
| AHD | Nhôm LME | Kim loại |
| FEF | Quặng sắt | Kim loại |
| LHC | Thép cuộn cán nóng | Kim loại |
| SSC | Thép phế liệu | Kim loại |
| SSR | Thép thanh vằn | Kim loại |
| NID | Nickel LME | Kim loại |
| ZDS | Kẽm LME | Kim loại |
| PBD | Chì LME | Kim loại |
| SND | Thiếc LME | Kim loại |
| SIE | Bạc | Kim loại |
| PLE | Bạch kim | Kim loại |
Quy tắc khi tin nói chung chung (không ghi rõ loại)
| Tin nói về | Mã gắn | Khi tin ghi rõ loại |
|---|---|---|
| Cà phê | KCE + LRC | Arabica → KCE; Robusta → LRC |
| Đường | SBE | Đường trắng → QW |
| Lúa mì | ZWA | Lúa mì Kansas / HRW → KWE |
| Cao su | ZFT | RSS3 / Osaka → TRU |
| Thép | LHC | Phế liệu → SSC; thanh vằn → SSR |
| Đậu tương | ZSE | Dầu đậu tương → ZLE; khô đậu → ZME |
products: [] là bình thường với: tin năng lượng (dầu thô, khí, xăng — chưa
có trong danh sách sản phẩm SFI), vàng, lithium, tin vĩ mô, địa chính trị và các tin không nhắc mặt
hàng cụ thể. Số thực tế ngày 15/10: khoảng 15% tin có mã. Muốn lọc tin năng lượng hoặc vĩ mô
thì dùng categories.
Gắn mã bằng từ khoá nên có thể sót hoặc nhầm ở số ít tin. Bảng từ khoá được chỉnh trên máy chủ, ứng dụng không cần sửa. Khi thấy tin gắn sai, gửi
id tin cho bên vận hành.
categories — nhóm theo bộ AI News Feed
Giá trị: 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.
| Trường hợp | categories |
|---|---|
| Tin có mã sản phẩm | Nhóm của các mã theo bảng trên (vd tin cà phê → Nguyên liệu công nghiệp, kể cả khi category là Nông sản). Nếu category là Năng lượng hoặc Vĩ mô thì giữ thêm nhóm đó (vd ["Kim loại", "Kinh tế vĩ mô"]) |
| Tin không có mã | Đổi từ category: Vĩ mô → Kinh tế vĩ mô; Năng lượng, Kim loại, Nông sản, Nguyên liệu công nghiệp giữ nguyên; Địa chính trị, Việt Nam, Hàng hóa, rỗng → Khác |
2.7 · Ghi chú về bộ lọc v1.4
- Bộ lọc chạy trên máy chủ. Đề nghị không tải hết tin về rồi lọc ở ứng dụng.
products=CPE,ZCElà OR (tin nhắc đồng hoặc ngô).products+categorieslà AND.from/totheo giờ Việt Nam (khác AI News Feed của tab Tin thường, tính theo UTC), so trênposted_at.- Có thể dùng
since(gọi định kỳ lấy tin mới) cùng bộ lọc:?products=KCE,LRC&since=<next_since>.
Báo cáo — GET /api/reports
/api/reports
3.1 · Tham số và phản hồi
| Tên | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| since | số | 0 | Chỉ lấy báo cáo có id lớn hơn giá trị này |
| limit | số | 50 | Số báo cáo tối đa mỗi lần, từ 1 đến 200 |
{
"items": [
{
"id": 1,
"posted_at": "2026-10-02T18:07:24+07:00",
"report": "cot",
"report_name": "COT – Quỹ đầu cơ (CFTC)",
"period": "2026-09-22",
"caption": "",
"images": [
"cot_2026-09-22_1.png",
"cot_2026-09-22_2.png"
]
}
],
"count": 1,
"next_since": 1
}
images đã đổi ở v1.4: mảng tên file ảnh, không kèm đường dẫn. Tải ảnh
bằng GET /api/reports/image/<tên file>. Bản trước trả
"/api/reports/image/cot_2026-09-22_1.png" — nếu ứng dụng đang ghép đường dẫn từ giá trị cũ
thì cần sửa theo dạng mới.
caption rỗng ở các báo cáo lịch sử. Báo cáo chỉ có con trỏ since /
next_since (cách ở §1.1), không có order, before hay bộ lọc.
Test — GET /api/reports
Bấm một chip tên file ảnh để tải ảnh đó ở khối test §4.
3.2 · Danh sách mã báo cáo
| report | report_name | Lịch công bố |
|---|---|---|
| inspections | Thanh tra xuất khẩu | Thứ Hai hằng tuần |
| export_sales | Doanh số xuất khẩu | Thứ Năm hằng tuần; tuần có ngày lễ Mỹ dời 1 ngày |
| crop_progress | Tiến độ gieo trồng | Thứ Ba hằng tuần (mùa vụ) |
| grain_stocks | Tồn kho ngũ cốc | Theo quý |
| wasde | WASDE | Hằng tháng |
| cot | COT – Quỹ đầu cơ (CFTC) | Thứ Bảy hằng tuần; tuần có ngày lễ Mỹ có thể dời |
| eia | Năng lượng EIA | Thứ Năm hằng tuần (giờ VN); tuần có ngày lễ Mỹ dời sang ngày sau |
| shfe_cu | Tồn kho đồng SHFE | Thứ Sáu hằng tuần |
| lme_cu | Tồn kho đồng LME | Thứ Sáu hằng tuần |
| unica_sugar | Đường Brazil – UNICA | Khoảng 2 tuần/kỳ, không có lịch cố định (thường tối/đêm giờ VN). 2 ảnh |
| br_xk_tuan | Xuất khẩu Brazil – tuần (Secex) | Hằng tuần, thứ Ba ~01:00 giờ VN. 2 ảnh |
| br_xk_thang | Xuất khẩu Brazil – tháng (Comex Stat) | Hằng tháng, khoảng ngày 5–7. 3 ảnh |
| conab_graos | CONAB – Mùa vụ & cung cầu Brazil | Hằng tháng, ~19:00 giờ VN ngày 13–15. 3 ảnh |
| conab_tiendo | CONAB – Tiến độ gieo trồng Brazil | Hằng tuần, không giờ cố định. 2 ảnh |
| conab_cafe | CONAB – Cà phê Brazil | 4 kỳ/năm. 2 ảnh |
| conab_cana | CONAB – Mía đường Brazil | 3–4 kỳ/năm. 2 ảnh |
| anec_xk | ANEC – Dự báo xuất khẩu Brazil (tuần) | mới v1.4 Hằng tuần, thường đêm thứ Ba – sáng thứ Tư giờ VN. 2 ảnh |
| cecafe_xk | Cecafé – Xuất khẩu cà phê Brazil (tháng) | mới v1.4 Hằng tháng, khoảng ngày 8–16. 2 ảnh |
| ciara_fx | CIARA-CEC – Ngoại tệ xuất khẩu nông sản Argentina | mới v1.4 Hằng tháng, khoảng ngày 1–3. 2 ảnh |
| bcr_ar | BCR Rosario – Ước tính sản lượng Argentina | mới v1.4 Hằng tháng, khoảng ngày 6–15. 2 ảnh |
Kỳ đầu tiên của 4 báo cáo mới xuất hiện trong API khi nguồn công bố kỳ tiếp theo (dự kiến từ 21/10/2026).
report, không theo report_name.
report_name là nhãn tiếng Việt cho người đọc và có thể được chỉnh lại theo thời gian — phần
mềm nên đối chiếu và lưu trữ theo mã report. Báo cáo mới có thể được bổ sung: ứng dụng nên
hiển thị được giá trị report chưa biết bằng cách dùng report_name làm nhãn và
hiển thị các ảnh trong images như bình thường.
3.3 · Định dạng period
Định dạng không thống nhất giữa các báo cáo. Hãy coi period là chuỗi: chỉ dùng để
hiển thị và so sánh bằng, không chuyển sang kiểu ngày tháng.
| Định dạng | Ví dụ | Báo cáo dùng định dạng này |
|---|---|---|
| YYYY-MM-DD | 2026-10-06 | inspections, export_sales, crop_progress, eia, cot, shfe_cu, lme_cu, unica_sugar, br_xk_tuan, br_xk_thang, conab_tiendo |
| YYYY-MM | 2026-10 | wasde, conab_graos, conab_cafe, conab_cana |
| YYYY-qn | 2026-q1 | grain_stocks |
Với unica_sugar, period là ngày chốt số của UNICA (vd
2026-08-01 = số liệu luỹ kế tới 01/08). Các báo cáo mới khác: coi period là
chuỗi như trên.
Ảnh báo cáo — GET /api/reports/image/{tên_file}
/api/reports/image/{tên_file}
Truyền tên file lấy từ mảng images ở §3.1. Trả về file PNG, kiểu image/png.
GET /api/reports/image/cot_2026-09-22_1.png
X-API-Key: <khoá>
Ảnh là biểu bảng số liệu đã dựng sẵn theo mẫu của công ty, khoảng 2100 × 1000 điểm ảnh, dung lượng 100–300 KB mỗi ảnh. Kích thước ảnh khác nhau giữa các báo cáo — ứng dụng nên co ảnh theo khung, không cố định kích thước.
X-API-Key, nên thẻ <img src> trỏ thẳng
tới API sẽ không tải được. Hãy tải qua backend (hoặc fetch kèm header rồi dựng
blob: URL) — khối test bên dưới làm đúng cách sau.
Test — GET /api/reports/image/{tên_file}
Kiểm tra tình trạng — GET /api/health
/api/health
Không cần khoá. Dùng cho hệ thống giám sát.
{
"ok": true,
"news": { "total": 737, "last_id": 737 },
"reports": { "total": 18, "last_id": 18 }
}
Khi hệ thống gặp sự cố, phản hồi trả về ok: false kèm mã HTTP 500; khối
news hoặc reports khi đó chứa trường error mô tả nguyên nhân. Đây
là dấu hiệu để hệ thống giám sát báo động.
Test — GET /api/health
Mã lỗi
| Mã | Ý nghĩa | Xử lý |
|---|---|---|
| 200 | Thành công | — |
| 400 | Tên file ảnh không hợp lệ; hoặc (v1.4) from / to không đọc được thành ngày | Kiểm lại tên file lấy từ images; ngày dùng dạng YYYY-MM-DD hoặc ISO-8601 |
| 401 | Thiếu hoặc sai khoá | Kiểm header X-API-Key |
| 404 | Không tìm thấy ảnh | Kiểm lại tên file lấy nguyên từ trường images của /api/reports |
| 422 | Tham số không hợp lệ | since, before âm hoặc limit ngoài khoảng cho phép — kiểm lại các giá trị này |
| 429 | Gọi quá nhanh | Chờ 1 giây rồi gọi lại |
| 500 | Lỗi hệ thống | Chỉ gặp ở /api/health khi cơ sở dữ liệu lỗi — liên hệ bên vận hành |
| 503 | Máy chủ chưa cấu hình khoá | Liên hệ bên vận hành |
Ví dụ gọi
Python — gọi định kỳ lấy tin mới
import time, requests
BASE = "http://42.96.44.143:8080"
KEY = "..."
since = 0
while True:
r = requests.get(f"{BASE}/api/news",
params={"since": since, "limit": 100},
headers={"X-API-Key": KEY}, timeout=10)
if r.status_code == 429:
time.sleep(1); continue
r.raise_for_status()
d = r.json()
for it in d["items"]:
print(it["posted_at"], it["categories"], it["products"], it["text_vi"])
since = d["next_since"]
time.sleep(5)
Python — danh sách tin đồng, cuộn về tin cũ v1.4
before = 0
while True:
r = requests.get(f"{BASE}/api/news",
params={"products": "CPE", "order": "desc",
"limit": 20, "before": before},
headers={"X-API-Key": KEY}, timeout=10)
d = r.json()
for it in d["items"]:
print(it["id"], it["text_vi"])
if not d["has_more"]:
break
before = d["next_before"] # luôn lấy con trỏ server trả về
C#
var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-API-Key", KEY);
int since = 0;
while (true) {
var res = await http.GetFromJsonAsync<NewsResponse>(
$"{BASE}/api/news?since={since}&limit=100");
foreach (var it in res.items)
Console.WriteLine($"{it.posted_at} {string.Join(",", it.products)} {it.text_vi}");
since = res.next_since;
await Task.Delay(5000);
}
Thử nhanh bằng curl
curl -H "X-API-Key: <khoá>" \
"http://42.96.44.143:8080/api/news?products=CPE,ZCE&order=desc&limit=5"
curl -H "X-API-Key: <khoá>" \
"http://42.96.44.143:8080/api/reports?since=0&limit=2"
curl -H "X-API-Key: <khoá>" -o thu.png \
"http://42.96.44.143:8080/api/reports/image/cot_2026-09-22_1.png"
Chính sách lưu trữ
| Loại | Thời gian giữ |
|---|---|
| Tin nhanh | 90 ngày |
| Báo cáo và ảnh | Không giới hạn hiện tại |