mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(misc): add /giaxang Petrolimex retail fuel prices
This commit is contained in:
1 parent
749cd516a3
commit
97aaba83ec
6 files changed
+417
-3
No files matched your search
@@ -8,7 +8,7 @@ Atlas via long polling and an in-process cron scheduler.
|
||||
| Module | What it does |
|
||||
|---|---|
|
||||
| `util` | `/help`, `/info`, `/stickerid` |
|
||||
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
|
||||
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/giaxang`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
|
||||
| `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only |
|
||||
| `wordle` | Daily Wordle game |
|
||||
| `loldle` | League-of-Legends "guess the champion" |
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package misc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
// petrolimexSearchURL is the CMS search endpoint behind the "Giá bán lẻ xăng
|
||||
// dầu" widget on petrolimex.com.vn. It is not a published API: the site calls
|
||||
// it from the browser, and it answers plain unauthenticated GETs. A variable so
|
||||
// tests can point it at an httptest server.
|
||||
var petrolimexSearchURL = "https://portals.petrolimex.com.vn/~apis/portals/cms.item/search"
|
||||
|
||||
// petrolimexPriceFilter selects the published retail fuel price records. The
|
||||
// IDs identify Petrolimex's CMS system, the price repository, and its entity;
|
||||
// they are kept readable here and base64-encoded into the x-request parameter
|
||||
// the endpoint expects.
|
||||
const petrolimexPriceFilter = `{"FilterBy":{"And":[` +
|
||||
`{"SystemID":{"Equals":"6783dc1271ff449e95b74a9520964169"}},` +
|
||||
`{"RepositoryID":{"Equals":"a95451e23b474fe5886bfb7cf843f53c"}},` +
|
||||
`{"RepositoryEntityID":{"Equals":"3801378fe1e045b1afa10de7c5776124"}},` +
|
||||
`{"Status":{"Equals":"Published"}}]},` +
|
||||
`"SortBy":{"LastModified":"Descending"},` +
|
||||
`"Pagination":{"TotalRecords":-1,"TotalPages":0,"PageSize":0,"PageNumber":0}}`
|
||||
|
||||
const (
|
||||
petrolimexHTTPTimeout = 5 * time.Second
|
||||
petrolimexMaxBytes = 1 << 20
|
||||
|
||||
giaxangErrorText = "Không lấy được giá xăng dầu từ Petrolimex. Thử lại sau nhé."
|
||||
)
|
||||
|
||||
// giaxangLocation renders the update time in Vietnam time. FixedZone matches
|
||||
// the rest of the repo and needs no tzdata in the container image.
|
||||
var giaxangLocation = time.FixedZone("Asia/Saigon", 7*60*60)
|
||||
|
||||
// fuelPrice is one retail product in VND per liter for each price zone.
|
||||
type fuelPrice struct {
|
||||
Title string `json:"Title"`
|
||||
Zone1Price float64 `json:"Zone1Price"`
|
||||
Zone2Price float64 `json:"Zone2Price"`
|
||||
DisplayOrder int `json:"DIsplayOrder"` // upstream spelling
|
||||
LastModified string `json:"LastModified"`
|
||||
}
|
||||
|
||||
type petrolimexSearchResponse struct {
|
||||
Objects []fuelPrice `json:"Objects"`
|
||||
}
|
||||
|
||||
// fetchFuelPrices returns the current Petrolimex retail prices in display
|
||||
// order. Records without a title or a positive Zone 1 price are dropped; an
|
||||
// empty result is an error so the caller never renders a blank table.
|
||||
func fetchFuelPrices(ctx context.Context, client *http.Client) ([]fuelPrice, error) {
|
||||
q := url.Values{}
|
||||
q.Set("object-identity", "search")
|
||||
q.Set("x-request", base64.StdEncoding.EncodeToString([]byte(petrolimexPriceFilter)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, petrolimexSearchURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("petrolimex: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("petrolimex: request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("petrolimex: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body petrolimexSearchResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, petrolimexMaxBytes)).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("petrolimex: decode: %w", err)
|
||||
}
|
||||
prices := make([]fuelPrice, 0, len(body.Objects))
|
||||
for _, p := range body.Objects {
|
||||
p.Title = strings.TrimSpace(p.Title)
|
||||
if p.Title == "" || p.Zone1Price <= 0 {
|
||||
continue
|
||||
}
|
||||
prices = append(prices, p)
|
||||
}
|
||||
if len(prices) == 0 {
|
||||
return nil, errors.New("petrolimex: no prices in response")
|
||||
}
|
||||
slices.SortStableFunc(prices, func(a, b fuelPrice) int { return cmp.Compare(a.DisplayOrder, b.DisplayOrder) })
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// formatFuelPrices renders the price table as Telegram HTML. The table sits in
|
||||
// a <pre> block so the zone columns line up in Telegram's monospace font.
|
||||
func formatFuelPrices(prices []fuelPrice) string {
|
||||
width := utf8.RuneCountInString("Mặt hàng")
|
||||
for _, p := range prices {
|
||||
width = max(width, utf8.RuneCountInString(p.Title))
|
||||
}
|
||||
|
||||
var table strings.Builder
|
||||
writeRow := func(name, zone1, zone2 string) {
|
||||
pad := strings.Repeat(" ", width-utf8.RuneCountInString(name))
|
||||
fmt.Fprintf(&table, "%s%s %6s %6s\n", name, pad, zone1, zone2)
|
||||
}
|
||||
writeRow("Mặt hàng", "Vùng 1", "Vùng 2")
|
||||
for _, p := range prices {
|
||||
writeRow(p.Title, formatThousands(p.Zone1Price), formatThousands(p.Zone2Price))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⛽ Giá bán lẻ xăng dầu Petrolimex (đ/lít)\n")
|
||||
if updated, ok := latestUpdate(prices); ok {
|
||||
sb.WriteString("Cập nhật: " + updated.In(giaxangLocation).Format("15:04 02/01/2006") + "\n")
|
||||
}
|
||||
sb.WriteString("\n<pre>" + html.EscapeString(strings.TrimRight(table.String(), "\n")) + "</pre>")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// latestUpdate returns the newest LastModified across the records, which is
|
||||
// when Petrolimex last published a price change.
|
||||
func latestUpdate(prices []fuelPrice) (time.Time, bool) {
|
||||
var latest time.Time
|
||||
for _, p := range prices {
|
||||
t, err := time.Parse(time.RFC3339, p.LastModified)
|
||||
if err == nil && t.After(latest) {
|
||||
latest = t
|
||||
}
|
||||
}
|
||||
return latest, !latest.IsZero()
|
||||
}
|
||||
|
||||
// formatThousands renders a VND amount with dot thousands separators, the
|
||||
// Vietnamese convention (27080 -> "27.080").
|
||||
func formatThousands(n float64) string {
|
||||
s := strconv.FormatInt(int64(n+0.5), 10)
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
sb.WriteByte(s[i])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func giaxangCommand() modules.Command {
|
||||
client := &http.Client{Timeout: petrolimexHTTPTimeout}
|
||||
return modules.Command{
|
||||
Name: "giaxang",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Giá xăng dầu hôm nay",
|
||||
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
if update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
fetchCtx, cancel := chathelper.FetchContext(ctx)
|
||||
prices, err := fetchFuelPrices(fetchCtx, client)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Error("fuel price fetch failed", "module", "misc", "command", "giaxang", "err", err)
|
||||
return chathelper.Reply(ctx, b, update.Message, giaxangErrorText)
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, update.Message, formatFuelPrices(prices))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package misc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// petrolimexFixture mirrors the live response shape: records arrive sorted by
|
||||
// LastModified, not display order, and may include entries with no price.
|
||||
const petrolimexFixture = `{"Objects":[
|
||||
{"Title":"DO 0,001S-V","Zone1Price":32090,"Zone2Price":32730,"DIsplayOrder":6,"LastModified":"2026-09-24T07:48:49.563Z"},
|
||||
{"Title":"Xăng E10 RON 95-III","Zone1Price":27080,"Zone2Price":27620,"DIsplayOrder":4,"LastModified":"2026-09-24T07:47:51.496Z"},
|
||||
{"Title":"Mazút","Zone1Price":0,"Zone2Price":0,"DIsplayOrder":9,"LastModified":"2026-09-24T08:30:00Z"},
|
||||
{"Title":" ","Zone1Price":1,"Zone2Price":1,"DIsplayOrder":1,"LastModified":"2026-09-24T07:00:00Z"}
|
||||
]}`
|
||||
|
||||
const giaxangFixtureReply = "⛽ Giá bán lẻ xăng dầu Petrolimex (đ/lít)\n" +
|
||||
"Cập nhật: 14:48 24/09/2026\n" +
|
||||
"\n<pre>" +
|
||||
"Mặt hàng Vùng 1 Vùng 2\n" +
|
||||
"Xăng E10 RON 95-III 27.080 27.620\n" +
|
||||
"DO 0,001S-V 32.090 32.730" +
|
||||
"</pre>"
|
||||
|
||||
// stubPetrolimex points the command at a test server for the duration of t.
|
||||
func stubPetrolimex(t *testing.T, handler http.HandlerFunc) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
orig := petrolimexSearchURL
|
||||
petrolimexSearchURL = server.URL + "/search"
|
||||
t.Cleanup(func() { petrolimexSearchURL = orig })
|
||||
}
|
||||
|
||||
func TestGiaxang_RepliesPriceTable(t *testing.T) {
|
||||
stubPetrolimex(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("object-identity"); got != "search" {
|
||||
t.Errorf("object-identity = %q, want search", got)
|
||||
}
|
||||
filter, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("x-request"))
|
||||
if err != nil || string(filter) != petrolimexPriceFilter {
|
||||
t.Errorf("x-request = %q (err %v), want encoded price filter", filter, err)
|
||||
}
|
||||
_, _ = w.Write([]byte(petrolimexFixture))
|
||||
})
|
||||
rb, _ := installMisc(t, 999)
|
||||
|
||||
// Public: a non-admin sender gets the table.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/giaxang"))
|
||||
|
||||
sent := rb.LastSent()
|
||||
if got := sent.Text(); got != giaxangFixtureReply {
|
||||
t.Errorf("/giaxang reply =\n%s\nwant\n%s", got, giaxangFixtureReply)
|
||||
}
|
||||
if got := sent.Form["parse_mode"]; got != "HTML" {
|
||||
t.Errorf("parse_mode = %q, want HTML", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiaxang_UpstreamFailureRepliesError(t *testing.T) {
|
||||
cases := map[string]http.HandlerFunc{
|
||||
"non-200": func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "blocked", http.StatusForbidden)
|
||||
},
|
||||
"bad json": func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<html>"))
|
||||
},
|
||||
"no prices": func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"Objects":[{"Title":"Mazút","Zone1Price":0}]}`))
|
||||
},
|
||||
}
|
||||
for name, handler := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stubPetrolimex(t, handler)
|
||||
rb, _ := installMisc(t, 999)
|
||||
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/giaxang"))
|
||||
|
||||
if got := rb.LastSent().Text(); got != giaxangErrorText {
|
||||
t.Errorf("/giaxang reply = %q, want %q", got, giaxangErrorText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatThousands(t *testing.T) {
|
||||
for in, want := range map[float64]string{0: "0", 999: "999", 27080: "27.080", 1234567: "1.234.567"} {
|
||||
if got := formatThousands(in); got != want {
|
||||
t.Errorf("formatThousands(%v) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
// /ping (public, exercises KV write), /ping_stats (protected, exercises KV
|
||||
// read), /random (public random picker), /wheelofnames (public wheel picker
|
||||
// with optional GIF), /ff (protected give-up-on-T1 rant), /xlt1 (public
|
||||
// apologise-to-T1 petition, the sequel to /ff), /the_answer (private easter
|
||||
// egg), and small public disclaimer commands.
|
||||
// apologise-to-T1 petition, the sequel to /ff), /giaxang (public Petrolimex
|
||||
// retail fuel prices), /the_answer (private easter egg), and small public
|
||||
// disclaimer commands.
|
||||
package misc
|
||||
|
||||
import (
|
||||
@@ -57,6 +58,7 @@ func New(deps modules.Deps) modules.Module {
|
||||
wheelOfNamesCommand(),
|
||||
ffCommand(),
|
||||
xlt1Command(),
|
||||
giaxangCommand(),
|
||||
theAnswerCommand(),
|
||||
disclaimerCommand("trongtruonghop", "Phát biểu disclaimer mặc định", defaultTarget, true),
|
||||
disclaimerCommand("tth", "Phát biểu disclaimer mặc định", defaultTarget, true),
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
|
||||
"wheelofnames": modules.VisibilityPublic,
|
||||
"ff": modules.VisibilityProtected,
|
||||
"xlt1": modules.VisibilityPublic,
|
||||
"giaxang": modules.VisibilityPublic,
|
||||
"the_answer": modules.VisibilityPrivate,
|
||||
"trongtruonghop": modules.VisibilityPublic,
|
||||
"tth": modules.VisibilityPublic,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# /giaxang — Vietnam retail fuel price source and design
|
||||
|
||||
Researched: 2026-09-30 11:27 (Asia/Saigon)
|
||||
|
||||
## Outcome
|
||||
|
||||
Use Petrolimex's own CMS JSON endpoint. It returns all six retail products with
|
||||
Zone 1 and Zone 2 prices in one unauthenticated GET, was verified live from this
|
||||
Oracle ARM64 host with plain `curl` (no special headers), and its numbers match
|
||||
today's press reports (E10 RON 95-III 27,080 / 27,620 đ, effective 2026-09-24).
|
||||
|
||||
## Source evaluation
|
||||
|
||||
| Source | Verdict | Evidence |
|
||||
|---|---|---|
|
||||
| Petrolimex CMS JSON (`portals.petrolimex.com.vn/~apis/portals/cms.item/search`) | **Use** | Live 200 response, 6 items, `Zone1Price`/`Zone2Price` numeric, `LastModified` per item. First-party data. |
|
||||
| VNAppMob (already used by `gold`) | Not available | Docs list only Province, Gold v2, Exchange rate v2; `/api/v2/petrol*` returns 404. |
|
||||
| petrolimex.com.vn HTML | Reject | Price table is rendered client-side (jQuery + crypto-js + rsa.js); nothing to parse server-side. |
|
||||
| Third-party aggregators (vietfuel-api, toanqng/fuel JSON on GitHub) | Reject as primary | Extra hop on someone's hobby infrastructure that itself scrapes Petrolimex. |
|
||||
| News sites (thanhnien, baomoi) | Reject | HTML scraping, unstable markup. |
|
||||
|
||||
## Endpoint contract
|
||||
|
||||
```text
|
||||
GET https://portals.petrolimex.com.vn/~apis/portals/cms.item/search
|
||||
?object-identity=search
|
||||
&x-request=<base64 JSON filter>
|
||||
```
|
||||
|
||||
The `x-request` value is base64 of a fixed filter (SystemID
|
||||
`6783dc1271ff449e95b74a9520964169`, RepositoryID
|
||||
`a95451e23b474fe5886bfb7cf843f53c`, RepositoryEntityID
|
||||
`3801378fe1e045b1afa10de7c5776124`, `Status=Published`, sorted by
|
||||
`LastModified` descending). Build it from a JSON literal in code rather than
|
||||
pasting an opaque base64 blob, so the IDs stay readable.
|
||||
|
||||
Fields used from each `Objects[]` entry:
|
||||
|
||||
| Field | Example | Use |
|
||||
|---|---|---|
|
||||
| `Title` | `Xăng E10 RON 95-III` | Product label |
|
||||
| `Zone1Price` | `27080` | VND/liter, vùng 1 |
|
||||
| `Zone2Price` | `27620` | VND/liter, vùng 2 |
|
||||
| `DIsplayOrder` (sic) | `4` | Sort order |
|
||||
| `LastModified` | `2026-09-24T07:47:51.496Z` | "Áp dụng từ" timestamp (max over items) |
|
||||
|
||||
Current product list: Xăng E10 RON 95-V, Xăng E10 RON 95-III, Xăng E5 RON 92-II,
|
||||
DO 0,001S-V, DO 0,05S-II, Dầu hỏa 2-K. Render whatever titles the API returns;
|
||||
do not hardcode the list (it changed with the 2026 E10 rollout).
|
||||
|
||||
## Brainstorm contract
|
||||
|
||||
- **Outcome:** `/giaxang` in the `misc` module replies with the current
|
||||
Petrolimex retail price table (all products, both zones, VND/liter) plus the
|
||||
effective timestamp in Asia/Saigon time.
|
||||
- **Constraints:** Go, follow existing `misc`/`gold` patterns; HTTP timeout under
|
||||
the handler deadline via `chathelper.FetchContext`; https-only endpoint with a
|
||||
test-overridable base URL (`httptest`); command metadata per
|
||||
`docs/command-parameter-conventions.md`; update README module table.
|
||||
- **Non-goals:** price history, change alerts/cron, PVOil or other retailers,
|
||||
per-province lookup, storing prices in MongoDB.
|
||||
- **Acceptance criteria:** handler test against an `httptest` server asserts the
|
||||
exact rendered text; upstream failure (non-200, bad JSON, empty list) yields a
|
||||
friendly Vietnamese error reply, not silence; registration/help tests pass;
|
||||
`go test ./...`, `go vet ./...`, `golangci-lint run` clean.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
| Approach | Load-bearing assumption | Fails first when |
|
||||
|---|---|---|
|
||||
| A. Fetch Petrolimex on every call (recommended) | Endpoint stays public and unauthenticated | Petrolimex adds auth/encryption to `~apis` or changes the IDs |
|
||||
| B. Fetch + in-memory cache (e.g. 10 min) | Same as A | Same as A; also adds staleness logic for little gain at chat volume |
|
||||
| C. Third-party aggregator JSON | Hobby project stays maintained | Maintainer stops updating (toanqng last push 2026-09-24, vietfuel 2026-07-16) |
|
||||
|
||||
Recommend A. Prices change at most weekly (Thursday adjustments), traffic is
|
||||
chat-scale, and the fetch is one small GET, so a cache is not needed yet.
|
||||
|
||||
Better approaches: none — recommended direction is the requested one (first-party
|
||||
JSON endpoint verified live).
|
||||
|
||||
## Proposed reply format
|
||||
|
||||
```text
|
||||
⛽ Giá bán lẻ xăng dầu Petrolimex
|
||||
Áp dụng từ 15:00 24/09/2026
|
||||
|
||||
Xăng E10 RON 95-V 28.080 | 28.640
|
||||
Xăng E10 RON 95-III 27.080 | 27.620
|
||||
Xăng E5 RON 92-II 26.390 | 26.910
|
||||
DO 0,001S-V 32.090 | 32.730
|
||||
DO 0,05S-II 30.490 | 31.090
|
||||
Dầu hỏa 2-K 30.020 | 30.620
|
||||
|
||||
đ/lít — Vùng 1 | Vùng 2
|
||||
```
|
||||
|
||||
Render inside `<pre>` (HTML) so columns align. Note: `LastModified` is when
|
||||
Petrolimex edited the record (07:47Z = 14:47 ICT), which is shortly before the
|
||||
official 15:00 effective time — label it "Cập nhật" rather than claiming the
|
||||
exact effective time.
|
||||
|
||||
## Risks
|
||||
|
||||
- Unofficial API: Petrolimex may change IDs or add request signing (their site
|
||||
already loads rsa.js/crypto-js). Mitigation: clear error reply + log; the
|
||||
source lives behind one small client so swapping is cheap.
|
||||
- Petrolimex reportedly blocks some cloud IP ranges (Google). Verified working
|
||||
from this Oracle host; the production Coolify host should be checked once
|
||||
after deploy.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Add `misc/giaxang_command.go` (client + formatter + command) and register it
|
||||
in `misc.New`.
|
||||
2. Add handler tests with `httptest`; update README module row and package doc.
|
||||
3. Run tests/vet/lint, commit `feat(misc): add /giaxang retail fuel price`.
|
||||
|
||||
## Sources
|
||||
|
||||
- Petrolimex endpoint (live probe, 2026-09-30)
|
||||
- [chiraitori/vngasprice](https://github.com/chiraitori/vngasprice) — Go scraper that documents the endpoint
|
||||
- [thanhnien.vn — Giá xăng dầu hôm nay 25.9.2026](https://thanhnien.vn/gia-xang-dau-hom-nay-2592026-xang-e10-len-muc-cao-nhat-28640-dong-lit-185260925083720959.htm) — cross-check of prices
|
||||
- [TranQui004/vietfuel-api](https://github.com/TranQui004/vietfuel-api), [toanqng/fuel](https://github.com/toanqng/fuel) — aggregator alternatives
|
||||
- [VNAppMob Open API docs](https://api.vnappmob.com/) — no fuel endpoint
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Visibility: public (like `/random`) or protected? Recommended: public.
|
||||
- Show both zones, or Zone 1 only for a shorter reply? Recommended: both.
|
||||
Reference in new issue
Block a user