Documentation
¶
Overview ¶
Package tgju reads the live currency, gold and coin boards published by tgju.org and returns them as Go values.
The site has no public API, so the package scrapes the price tables its pages are built from. That is a deliberate boundary: everything fragile — the class names, the column order, the Persian digits — lives behind one small internal package, and everything a caller touches is an ordinary struct.
As a library ¶
Build one Client and keep it; it owns the HTTP connection pool and a short lived snapshot cache.
client := tgju.New()
snap, err := client.Currency(context.Background())
if err != nil {
return err
}
dollar, ok := snap.Lookup("price_dollar_rl")
if ok {
fmt.Println(dollar.Title, dollar.Price.Text, dollar.Price.Toman())
}
A Snapshot is one board at one moment, grouped into the Category tables the site renders. Range over every row with Snapshot.All, or flatten with Snapshot.Items.
Looking up a single instrument without caring which board it sits on:
item, err := client.Item(ctx, "geram18")
As a service ¶
The sibling package github.com/amiranmanesh/tgju-api-go/server wraps a client in an net/http.Handler, so the same code either runs as the standalone binary in cmd/tgju or mounts inside an existing service:
mux.Handle("/tgju/", http.StripPrefix("/tgju", server.New(client)))
Caching and concurrency ¶
A Client is safe for concurrent use. Snapshots are cached for DefaultCacheTTL and concurrent misses for the same market are collapsed into one outgoing request, so a busy API server talks to tgju.org once per window rather than once per caller. Turn the cache off with WithCacheTTL(0).
Errors ¶
Every failure is an *Error wrapping one of the sentinels — ErrRequest, ErrUnexpectedStatus, ErrParse, ErrEmpty, ErrNotFound, ErrUnknownMarket — so both the category and the detail survive:
if errors.Is(err, tgju.ErrParse) {
// tgju changed its markup; alert, do not retry
}
var tgjuErr *tgju.Error
if errors.As(err, &tgjuErr) && tgjuErr.Temporary() {
// worth trying again later
}
Units ¶
tgju quotes currency, gold and coin prices in Iranian rial. Amount keeps both the site's own rendering and the parsed number, and offers Amount.Toman for the unit people actually speak in.
Example ¶
package main
import (
"context"
"fmt"
"log"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
client := tgju.New()
snap, err := client.Currency(context.Background())
if err != nil {
log.Fatal(err)
}
dollar, ok := snap.Lookup("price_dollar_rl")
if !ok {
log.Fatal("tgju no longer publishes the dollar rate")
}
fmt.Println(dollar.Title, dollar.Price.Text, "rial")
fmt.Println(dollar.Title, dollar.Price.Toman(), "toman")
}
Output:
Index ¶
- Constants
- Variables
- type Amount
- type Category
- type Change
- type Client
- func (c *Client) BaseURL() string
- func (c *Client) CacheTTL() time.Duration
- func (c *Client) Coin(ctx context.Context) (Snapshot, error)
- func (c *Client) Currency(ctx context.Context) (Snapshot, error)
- func (c *Client) Fetch(ctx context.Context, m Market) (Snapshot, error)
- func (c *Client) FetchAll(ctx context.Context, markets ...Market) (map[Market]Snapshot, error)
- func (c *Client) Gold(ctx context.Context) (Snapshot, error)
- func (c *Client) Invalidate(markets ...Market)
- func (c *Client) Item(ctx context.Context, key string, markets ...Market) (Item, error)
- type Doer
- type Error
- type Item
- type Market
- type Option
- func WithBaseURL(u string) Option
- func WithCacheTTL(d time.Duration) Option
- func WithClock(now func() time.Time) Option
- func WithHTTPClient(d Doer) Option
- func WithHeader(name, value string) Option
- func WithLogger(l *slog.Logger) Option
- func WithMaxBodyBytes(n int64) Option
- func WithRetry(p RetryPolicy) Option
- func WithTimeout(d time.Duration) Option
- func WithUserAgent(ua string) Option
- type RetryPolicy
- type Snapshot
- type Status
Examples ¶
Constants ¶
const ( // DefaultBaseURL is the public site. Override it with [WithBaseURL] to // point the client at a mirror, a caching proxy or a test server. DefaultBaseURL = "https://www.tgju.org" // DefaultTimeout bounds one page fetch, retries included. DefaultTimeout = 20 * time.Second // DefaultMaxBodyBytes caps how much of a response is read. tgju pages are // around one megabyte; the cap exists so a broken or hostile upstream // cannot exhaust the memory of a long lived service. DefaultMaxBodyBytes int64 = 16 << 20 // DefaultCacheTTL is how long a snapshot is served from memory before the // page is fetched again. tgju updates prices every few seconds, so a short // window keeps the data fresh while collapsing a burst of API requests // into one outgoing request. DefaultCacheTTL = 30 * time.Second )
Defaults applied by New when the caller sets nothing.
const DefaultUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 tgju-api-go/" + Version
DefaultUserAgent is sent with every request. tgju serves an error page to clients that do not look like browsers, so the default impersonates one while still naming this library, which is the honest compromise: an operator reading their logs can tell what is calling them.
const Version = "1.0.1"
Version is the release of this library, following semantic versioning.
It is compiled in rather than read from build info so that the value is available to DefaultUserAgent at package initialisation, and so that a caller vendoring the source still reports something meaningful. The binary in cmd/tgju overrides its own copy at link time with the git tag.
Variables ¶
var ( // ErrUnknownMarket is returned for a market name the library does not // serve. ErrUnknownMarket = errors.New("tgju: unknown market") // ErrRequest is returned when the request to tgju.org could not be made or // completed: DNS, TLS, a dropped connection, a cancelled context. ErrRequest = errors.New("tgju: request to tgju.org failed") // ErrUnexpectedStatus is returned when tgju.org answered with a status // other than 200. Read [Error.StatusCode] for the code itself. ErrUnexpectedStatus = errors.New("tgju: unexpected status from tgju.org") // ErrParse is returned when the page could be fetched but not understood, // which almost always means tgju changed its markup. ErrParse = errors.New("tgju: could not parse the tgju.org page") // ErrEmpty is returned when the page parsed cleanly but held no rows. ErrEmpty = errors.New("tgju: the page carried no prices") // ErrNotFound is returned by lookups for an instrument key that the board // does not publish. ErrNotFound = errors.New("tgju: no such instrument") // ErrTooLarge is returned when a response exceeds the configured body // limit, which protects a long lived service from a hostile or broken // upstream. ErrTooLarge = errors.New("tgju: response body is too large") )
Sentinel errors returned by this package. Compare them with errors.Is; the detail of a particular failure is carried by Error, which wraps one of them.
var DefaultRetry = RetryPolicy{MaxAttempts: 3, Backoff: 300 * time.Millisecond, MaxBackoff: 2 * time.Second}
DefaultRetry retries twice with a short, doubling pause. Two extra attempts cover the connection resets tgju hands out under load without turning a genuine outage into a minute of blocked goroutines.
Functions ¶
This section is empty.
Types ¶
type Amount ¶
type Amount struct {
// Text is the site's own rendering, e.g. "1,864,000".
Text string `json:"text"`
// Value is Text parsed as a number. Currency, gold and coin prices are
// quoted in Iranian rial.
Value float64 `json:"value"`
}
Amount is a price as tgju renders it together with its numeric value.
Both halves are kept because they answer different questions: Text is what you show a Persian speaking user, Value is what you compare, sort and store. Parsing is done once, during scraping, so a caller never has to strip thousands separators itself.
Example ¶
Converting to toman and rounding to the nearest thousand is the kind of thing the parsed value is there for.
package main
import (
"fmt"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
amount := tgju.Amount{Text: "1,864,000", Value: 1_864_000}
fmt.Println(amount.Text)
fmt.Println(amount.Rial())
fmt.Println(amount.Toman())
}
Output: 1,864,000 1864000 186400
func (Amount) IsZero ¶
IsZero reports whether the amount carries no value at all, which is how an empty table cell arrives.
func (Amount) String ¶
String implements fmt.Stringer and returns the site's rendering, falling back to the numeric value when the cell was built from an attribute.
type Category ¶
type Category struct {
// Title is the caption, e.g. "قیمت طلا" or "حباب سکه".
Title string `json:"title"`
// Items are the rows of the table, in the order the site published them.
Items []Item `json:"items"`
}
Category is one table of a board, named after the caption tgju puts in its first header cell.
type Change ¶
type Change struct {
// Status is the direction of the move.
Status Status `json:"status"`
// Percent is the size of the move in percent, always positive; read
// Status for the sign.
Percent float64 `json:"percent"`
// Amount is the size of the move in rials, always positive.
Amount Amount `json:"amount"`
}
Change is the move of an instrument since the previous close.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client reads price boards from tgju.org.
A Client is safe for concurrent use and is meant to be built once and kept: it owns the HTTP connection pool and the snapshot cache, both of which are wasted when a client is created per request.
The zero value is not usable; call New.
func New ¶
New returns a client configured by opts.
client := tgju.New(
tgju.WithTimeout(10*time.Second),
tgju.WithCacheTTL(time.Minute),
)
Example ¶
A client owns a connection pool and a cache, so build it once and keep it for the lifetime of the program.
package main
import (
"time"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
client := tgju.New(
tgju.WithTimeout(10*time.Second),
tgju.WithCacheTTL(time.Minute),
tgju.WithUserAgent("acme-pricing/2.1"),
)
_ = client
}
Output:
Example (AsAService) ¶
The HTTP API is an ordinary handler, so it can be the whole service or one subtree of a larger one.
package main
import (
"fmt"
"log"
"net/http"
"time"
tgju "github.com/amiranmanesh/tgju-api-go"
"github.com/amiranmanesh/tgju-api-go/server"
)
func main() {
client := tgju.New(tgju.WithCacheTTL(30 * time.Second))
mux := http.NewServeMux()
mux.Handle("/prices/", http.StripPrefix("/prices", server.New(client)))
mux.HandleFunc("GET /", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "my own service")
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
Output:
func (*Client) CacheTTL ¶
CacheTTL returns how long snapshots are reused. Zero means the cache is off.
func (*Client) Fetch ¶
Fetch returns the current snapshot of a board, serving it from the cache when one was taken within the configured TTL.
Concurrent calls for the same market while the cache is cold are collapsed into a single request to tgju.org.
func (*Client) FetchAll ¶
FetchAll returns a snapshot per market, fetched concurrently. Passing no market fetches every supported one.
It is all or nothing: the first failure is returned and the partial result is discarded, because a caller that wanted "whatever succeeded" can loop over [Fetch] itself and decide what a hole in the data means for it.
func (*Client) Invalidate ¶
Invalidate drops cached snapshots for the given markets, or for all of them when none is named. The next fetch goes to tgju.org.
func (*Client) Item ¶
Item finds a single instrument by its tgju key — "price_dollar_rl", "geram18", "sekee" — across the given markets, or across all of them when none is named.
It returns an error wrapping ErrNotFound when no board publishes the key. With the cache on this costs at most one fetch per market per TTL, so it is a reasonable call to make per HTTP request in a service.
Example ¶
Item searches every board, which is what you want when a configuration file names instruments but not the pages they live on.
package main
import (
"context"
"fmt"
"log"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
client := tgju.New()
for _, key := range []string{"price_dollar_rl", "geram18", "sekee"} {
item, err := client.Item(context.Background(), key)
if err != nil {
log.Printf("%s: %v", key, err)
continue
}
fmt.Printf("%s (%s): %s\n", item.Title, item.Market, item.Price.Text)
}
}
Output:
type Doer ¶
type Doer interface {
// Do executes an HTTP request and returns its response.
Do(req *http.Request) (*http.Response, error)
}
Doer is the subset of http.Client this package needs. Supply your own to plug in tracing, connection pooling policy, a proxy or a stub.
type Error ¶
type Error struct {
// Op is the operation that failed: "fetch", "parse" or "lookup".
Op string
// Market is the board being read, when the failure is tied to one.
Market Market
// URL is the address that was requested.
URL string
// StatusCode is the HTTP status tgju answered with, or zero when the
// request never produced a response.
StatusCode int
// Attempts is how many times the request was tried before giving up.
Attempts int
// Err is the wrapped sentinel or transport error.
Err error
}
Error is the rich error every fetch returns. It keeps the market, the URL and the upstream status so a caller can log them, while still unwrapping to one of the sentinels above.
Example ¶
Every failure carries both a category and its detail, so a caller can decide between retrying, alerting and giving up.
package main
import (
"context"
"errors"
"log"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
_, err := tgju.New().Gold(context.Background())
if err == nil {
return
}
switch {
case errors.Is(err, tgju.ErrParse):
// tgju changed its markup: retrying will not help.
log.Fatal("the scraper needs an update: ", err)
case errors.Is(err, tgju.ErrNotFound):
log.Print("no such instrument")
default:
var tgjuErr *tgju.Error
if errors.As(err, &tgjuErr) && tgjuErr.Temporary() {
log.Printf("upstream had a bad moment (status %d), will retry", tgjuErr.StatusCode)
return
}
log.Print(err)
}
}
Output:
type Item ¶
type Item struct {
// Key is tgju's own identifier, e.g. "price_dollar_rl" or "geram18". It is
// stable across page redesigns and is what you should store.
Key string `json:"key"`
// Title is the Persian name, e.g. "دلار".
Title string `json:"title"`
// Market is the board the item was read from.
Market Market `json:"market"`
// Category is the caption of the table the item sat in, e.g. "قیمت نقره".
Category string `json:"category"`
// Price is the live price.
Price Amount `json:"price"`
// Low and High are the extremes of the current trading day.
Low Amount `json:"low"`
High Amount `json:"high"`
// Change is the move since the previous close.
Change Change `json:"change"`
// Time is the timestamp tgju prints next to the row: a clock for actively
// traded instruments ("11:49:45") and a Persian date for stale ones
// ("24 مرداد"). It is passed through as text because the site gives no
// year, no timezone and no consistent format.
Time string `json:"time"`
// ProfileURL points at the instrument's page on tgju.org.
ProfileURL string `json:"profile_url,omitempty"`
}
Item is a single instrument on a board: a currency pair, a gold weight, a coin.
type Market ¶
type Market string
Market is one of the price pages tgju.org publishes. It is the only thing a caller has to name to fetch data, and it doubles as the path segment of the HTTP API exposed by the server package.
const ( // Currency is the foreign exchange board, https://www.tgju.org/currency. Currency Market = "currency" // Gold is the gold, silver and mesghal board, // https://www.tgju.org/gold-chart. Gold Market = "gold" // Coin is the Bahar Azadi coin board, https://www.tgju.org/coin. Coin Market = "coin" )
The supported markets.
Every one of them is rendered by tgju with the same table markup, which is what makes a single scraper enough. Pages built by client side JavaScript — the crypto board, for instance — are deliberately absent: scraping them would need a browser, and a browser has no place in a library.
func ParseMarket ¶
ParseMarket resolves a market name, case insensitively and tolerating the aliases that read naturally in a URL or on a command line.
Example ¶
Markets can be resolved from a string, which is how a configuration file or a command line argument becomes a fetch.
package main
import (
"fmt"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
for _, name := range []string{"gold", "FX", "سکه", "crypto"} {
market, err := tgju.ParseMarket(name)
if err != nil {
fmt.Printf("%s: %v\n", name, err)
continue
}
fmt.Printf("%s: %s (%s)\n", name, market, market.Label())
}
}
Output: gold: gold (طلا و نقره) FX: currency (ارز) سکه: coin (سکه) crypto: tgju: unknown market: "crypto"
func (Market) Path ¶
Path returns the path of the market page relative to the site root, or "" for an unknown market.
type Option ¶
type Option func(*config)
Option configures a Client. Options are applied in order, so a later one wins.
func WithBaseURL ¶
WithBaseURL points the client at another host. The trailing slash is optional. It exists for mirrors, corporate proxies and, above all, tests.
func WithCacheTTL ¶
WithCacheTTL sets how long a fetched snapshot is reused. Zero disables the cache, and with it the collapsing of concurrent fetches for the same market.
Leave the cache on when the client backs an HTTP API: it is the difference between one request to tgju per window and one per caller.
func WithClock ¶
WithClock replaces the source of time. Tests use it to drive cache expiry without sleeping.
func WithHTTPClient ¶
WithHTTPClient replaces the HTTP client used for outgoing requests.
The client keeps its own per call deadline, so a http.Client passed here does not need a Timeout of its own.
func WithHeader ¶
WithHeader adds a header to every outgoing request. Call it repeatedly to set several; a repeated name replaces the previous value.
func WithLogger ¶
WithLogger sends request and cache events to a slog.Logger at debug level, and upstream failures at warn level. The default logger discards everything.
func WithMaxBodyBytes ¶
WithMaxBodyBytes caps how much of a response is read. Zero or less restores DefaultMaxBodyBytes.
func WithRetry ¶
func WithRetry(p RetryPolicy) Option
WithRetry replaces the retry policy. Pass RetryPolicy{} to disable retrying.
func WithTimeout ¶
WithTimeout bounds one call to Client.Fetch, retries and backoff included. Zero or less restores DefaultTimeout.
func WithUserAgent ¶
WithUserAgent overrides the User-Agent header. An empty value is ignored; tgju answers requests without one with an error page.
type RetryPolicy ¶
type RetryPolicy struct {
// MaxAttempts is the total number of attempts including the first one.
// Values below one disable retrying.
MaxAttempts int
// Backoff is the pause before the second attempt. It doubles after each
// further failure.
Backoff time.Duration
// MaxBackoff caps the pause. Zero means uncapped.
MaxBackoff time.Duration
}
RetryPolicy controls how transport failures are retried. Fetching a price board is idempotent, so retrying is always safe.
type Snapshot ¶
type Snapshot struct {
// Market is the board this snapshot came from.
Market Market `json:"market"`
// Source is the URL that was fetched.
Source string `json:"source"`
// FetchedAt is when the page was retrieved, in UTC.
FetchedAt time.Time `json:"fetched_at"`
// Categories are the tables of the board, in page order.
Categories []Category `json:"categories"`
}
Snapshot is everything one board published at one moment.
It is a value: copying it is cheap enough and it is safe to share between goroutines as long as nobody mutates the slices it points at.
func (Snapshot) All ¶
All iterates over every item of the snapshot in page order.
for item := range snap.All() {
fmt.Println(item.Key, item.Price.Text)
}
Example ¶
Ranging over a snapshot visits every instrument of every category in the order the site published them.
package main
import (
"context"
"fmt"
"log"
tgju "github.com/amiranmanesh/tgju-api-go"
)
func main() {
snap, err := tgju.New().Gold(context.Background())
if err != nil {
log.Fatal(err)
}
for item := range snap.All() {
fmt.Printf("%-24s %14s %s\n", item.Key, item.Price.Text, item.Change.Status)
}
}
Output:
func (Snapshot) Category ¶
Category returns the category with the given title. The currency board publishes two tables under the same generic caption, so the first match wins.
func (Snapshot) Items ¶
Items flattens the snapshot into a freshly allocated slice. Prefer [All] when you only need to walk the items once.
type Status ¶
type Status string
Status is the direction of an instrument's move since the previous close, as tgju itself classifies it. It is read from the markup rather than derived from the numbers, because the site renders the change unsigned.
const ( // StatusUnknown means tgju published no direction for the row, which it // does for instruments that have not moved and for stale boards. StatusUnknown Status = "" // StatusLow means the price fell. StatusLow Status = "low" // StatusHigh means the price rose. StatusHigh Status = "high" )
The possible values of Status.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
tgju
command
Command tgju reads the tgju.org price boards from a terminal, and serves them over HTTP.
|
Command tgju reads the tgju.org price boards from a terminal, and serves them over HTTP. |
|
examples
|
|
|
alert
command
Command alert watches an instrument and prints a line when it crosses a threshold.
|
Command alert watches an instrument and prints a line when it crosses a threshold. |
|
basic
command
Command basic prints today's gold and currency prices.
|
Command basic prints today's gold and currency prices. |
|
embed
command
Command embed shows the library and the HTTP API living inside somebody else's service.
|
Command embed shows the library and the HTTP API living inside somebody else's service. |
|
internal
|
|
|
cmd/checkdocs
command
Command checkdocs enforces the supply-chain rules for the GitHub Pages site.
|
Command checkdocs enforces the supply-chain rules for the GitHub Pages site. |
|
cmd/checkspec
command
Command checkspec sanity checks the OpenAPI document before it is published.
|
Command checkspec sanity checks the OpenAPI document before it is published. |
|
cmd/fixtures
command
Command fixtures refreshes the saved tgju.org pages the tests parse.
|
Command fixtures refreshes the saved tgju.org pages the tests parse. |
|
cmd/healthcheck
command
Command healthcheck probes a running tgju server and exits 0 when it is healthy.
|
Command healthcheck probes a running tgju server and exits 0 when it is healthy. |
|
dom
Package dom is a thin query layer over golang.org/x/net/html.
|
Package dom is a thin query layer over golang.org/x/net/html. |
|
fixture
Package fixture serves saved tgju.org pages to the test suites.
|
Package fixture serves saved tgju.org pages to the test suites. |
|
numfmt
Package numfmt normalises the numbers tgju.org renders for a Persian audience into values a Go program can compute with.
|
Package numfmt normalises the numbers tgju.org renders for a Persian audience into values a Go program can compute with. |
|
scrape
Package scrape turns a tgju.org market page into plain rows of text.
|
Package scrape turns a tgju.org market page into plain rows of text. |
|
Package server exposes a tgju.Client over HTTP.
|
Package server exposes a tgju.Client over HTTP. |