tgju

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: MIT Imports: 16 Imported by: 0

README

tgju-api-go

Live Iranian currency, gold and coin prices — as a Go library and as a JSON API.

CI Go Reference Go Report Card Docs

Documentation · API reference · Wiki · Go package


tgju.org publishes the exchange rates, gold weights and coin prices most of Iran quotes, and has no public API. This project reads the price tables its pages are built from and gives them back two ways:

  • import it, and call client.Gold(ctx) in your own process — no HTTP hop, no sidecar, no second thing to operate.
  • docker run it, and call GET /v1/markets/gold over HTTP — from Python, from Node, from a browser, from anything.

Same client behind both, so they cannot disagree about what a price is.

It reimplements BlackIQ/tgju-api in Go, and keeps that project's response shape under /api/price/* so an existing consumer changes one hostname and nothing else.

Contents

Install

# as a module
go get github.com/amiranmanesh/tgju-api-go

# as a binary
go install github.com/amiranmanesh/tgju-api-go/cmd/tgju@latest

# as a container
docker pull ghcr.io/amiranmanesh/tgju-api-go:latest

Go 1.26 or newer. One dependency: golang.org/x/net/html.

As a library

package main

import (
    "context"
    "fmt"
    "log"

    tgju "github.com/amiranmanesh/tgju-api-go"
)

func main() {
    client := tgju.New()

    snap, err := client.Gold(context.Background())
    if err != nil {
        log.Fatal(err)
    }

    item, ok := snap.Lookup("geram18")
    if !ok {
        log.Fatal("tgju no longer publishes 18 carat gold")
    }

    fmt.Println(item.Title)             // طلای 18 عیار / 750
    fmt.Println(item.Price.Text)        // 190,542,000
    fmt.Println(item.Price.Toman())     // 1.9054200e+07
    fmt.Println(item.Change.Status)     // low
}

Build the client once and keep it: it owns the connection pool and the snapshot cache, and it is safe for concurrent use.

Ranging over a board, without allocating a slice:

for item := range snap.All() {
    fmt.Printf("%-22s %14s %s\n", item.Key, item.Price.Text, item.Change.Status)
}

Looking something up without caring which board it is on:

item, err := client.Item(ctx, "price_dollar_rl")   // searches every market
item, err := client.Item(ctx, "geram18", tgju.Gold) // or just one

Everything at once, fetched concurrently:

all, err := client.FetchAll(ctx)   // map[tgju.Market]tgju.Snapshot

More in the library guide.

As a service

docker run -p 8080:8080 ghcr.io/amiranmanesh/tgju-api-go:latest
curl localhost:8080/v1/markets                        # what boards exist
curl localhost:8080/v1/markets/gold                   # a whole board
curl localhost:8080/v1/items/price_dollar_rl          # one instrument
curl localhost:8080/v1/snapshot                       # everything

# filtered
curl 'localhost:8080/v1/markets/currency/items?keys=price_dollar_rl,price_eur'

# the original Python API's shape, unchanged
curl localhost:8080/api/price/currency
Method Path Returns
GET /v1/markets The supported boards
GET /v1/markets/{market} One board, grouped into categories
GET /v1/markets/{market}/items One board, flattened
GET /v1/markets/{market}/items/{key} One instrument of one board
GET /v1/items/{key} One instrument, across every board
GET /v1/snapshot Every board in one response
GET /api/price/{currency,gold,coin} The original API's shape
GET /healthz /readyz Liveness, readiness
GET /metrics Prometheus
GET /openapi.yaml /docs The description, and a reference page

{market} accepts currency, gold and coin, plus the aliases fx, gold-chart, coins, ارز, طلا, سکه, case insensitively.

Full reference: amiranmanesh.github.io/tgju-api-go/api.html.

Both at once

The API is an ordinary http.Handler, which is the point of the whole design: it can be one subtree of a service that also uses the library directly, with a shared cache between them.

client := tgju.New(tgju.WithCacheTTL(30 * time.Second))

mux := http.NewServeMux()

// the library half: your own logic, in process
mux.HandleFunc("GET /shop/quote", func(w http.ResponseWriter, r *http.Request) {
    gold, err := client.Item(r.Context(), "geram18", tgju.Gold)
    if err != nil {
        http.Error(w, "prices unavailable", http.StatusServiceUnavailable)
        return
    }
    fmt.Fprintf(w, "%.0f toman per gram\n", gold.Price.Toman())
})

// the service half: the ready made API, mounted under a prefix
mux.Handle("/prices/", http.StripPrefix("/prices", server.New(client)))

A quote and an API call arriving in the same window cost one fetch from tgju between them. Runnable version: examples/embed.

The CLI

tgju serve [flags]           start the HTTP API
tgju get <market> [flags]    print one board
tgju item <key> [flags]      print one instrument
tgju watch <key> [flags]     follow one instrument until interrupted
tgju markets                 list the supported boards
tgju version                 print the version
$ tgju get gold --unit toman
قیمت طلا
KEY             TITLE               PRICE       LOW         HIGH        CHANGE   TIME
geram18         طلای 18 عیار / 750  19,054,200  19,039,400  19,069,200  ▼ 0.13%  11:49:55
gold_740k       طلای 18 عیار / 740  18,800,100  18,785,600  18,814,900  ▼ 0.13%  11:49:55
geram24         طلای 24 عیار        25,405,300  25,385,600  25,425,300  ▼ 0.13%  11:49:55

قیمت نقره
KEY         TITLE         PRICE    LOW      HIGH     CHANGE   TIME
silver_925  گرم نقره 925  376,590  374,650  380,470  ▲ 0.47%  11:44:25
tgju get currency --format json | jq '.categories[].items[] | select(.change.percent > 1)'
tgju get coin --format csv > coins.csv
tgju watch price_dollar_rl --interval 30s

Exit codes: 0 success, 1 failure, 2 bad usage, 3 tgju could not be read — so a script can tell "the site is down" from "you typed it wrong".

What you get back

type Item struct {
    Key        string   // "price_dollar_rl" — stable across redesigns; store this
    Title      string   // "دلار"
    Market     Market   // "currency"
    Category   string   // the caption of the table it sat in
    Price      Amount
    Low, High  Amount   // the day's extremes
    Change     Change
    Time       string   // "11:49:45", or "24 مرداد" on a stale board
    ProfileURL string
}

An Amount keeps both halves of a price, because they answer different questions:

item.Price.Text     // "1,864,000" — what you show a Persian speaking user
item.Price.Value    // 1864000     — what you compare, sort and store
item.Price.Toman()  // 186400      — the unit people actually speak in

tgju renders the daily change unsigned, so the direction lives in a field of its own rather than in the sign of a number:

item.Change.Status    // StatusLow, StatusHigh, or StatusUnknown
item.Change.Percent   // 0.32, always positive
item.Change.Signum()  // -1, +1 or 0 — for arithmetic

Over the wire:

{
  "key": "price_dollar_rl",
  "title": "دلار",
  "market": "currency",
  "category": "عنوان",
  "price": { "text": "1,864,000", "value": 1864000 },
  "low":   { "text": "1,860,800", "value": 1860800 },
  "high":  { "text": "1,869,100", "value": 1869100 },
  "change": {
    "status": "low",
    "percent": 0.32,
    "amount": { "text": "6,050", "value": 6050 }
  },
  "time": "11:49:45",
  "profile_url": "https://www.tgju.org/profile/price_dollar_rl"
}

Prices are in rial, as tgju quotes them.

Markets

Market Board Source
currency ارز tgju.org/currency
gold طلا و نقره tgju.org/gold-chart
coin سکه tgju.org/coin

The crypto board is built by client-side JavaScript and is deliberately absent: reading it would need a headless browser, and a headless browser has no place in a library you import to look up an exchange rate.

Key catalogue: Instrument keys.

Errors

Every failure is a *tgju.Error wrapping a sentinel, so both the category and the detail survive the trip:

switch {
case errors.Is(err, tgju.ErrParse):
    // tgju changed its markup. Retrying will never work; this needs a release.
    alert(err)

case errors.Is(err, tgju.ErrNotFound):
    return errNoSuchInstrument

default:
    var tgjuErr *tgju.Error
    if errors.As(err, &tgjuErr) && tgjuErr.Temporary() {
        return retryLater(err)
    }
}

The distinction that matters is between "tgju is down" and "tgju changed". The first is worth a retry; the second is a busy loop. They are separate sentinels, separate API codes (upstream_unavailable and upstream_changed), and worth separate alerts.

Full table: Errors.

Caching

Snapshots are held for thirty seconds by default, and concurrent misses for the same board are collapsed into a single request upstream. That second property is the one that matters under load: without it, a cold cache with a hundred callers sends a hundred requests to tgju.

tgju.New(tgju.WithCacheTTL(time.Minute))
tgju.New(tgju.WithCacheTTL(0))        // off, and the collapsing with it
client.Invalidate(tgju.Gold)          // drop one board

Responses carry Cache-Control: public, max-age=<remaining TTL>, so a CDN in front of the service repeats the same policy one layer out.

Configuration

Every flag of tgju serve has an environment variable — upper case, TGJU_ prefixed, dashes as underscores. The flag wins when both are set.

TGJU_ADDR=:8080
TGJU_CACHE_TTL=30s
TGJU_TIMEOUT=20s
TGJU_RETRIES=3
TGJU_RATE_LIMIT=20
TGJU_RATE_BURST=40
TGJU_CORS=*
TGJU_LOG_LEVEL=info
TGJU_LOG_JSON=true

The complete list is in docker-compose.yml and in tgju serve -h.

Design

The fragile part is small, and it is isolated. internal/scrape knows about tables, header cells and slugs, and nothing about currencies, gold or rials. It returns text. convert.go turns that text into domain types. A tgju redesign is a change to one package, proved by fixtures.

The parser follows the header, not the column index. The layout is read from the Persian captions (قیمت زنده, کمترین, بیشترین, …), so a reshuffle upstream cannot silently swap the daily low with the daily high.

Persian numerals are a first-class concern. Persian and Arabic-Indic digits, ٬ and , grouping, ٫ and . decimals, and zero-width joiners are all normalised before anything is parsed — and the parser is fuzzed.

Failure modes are distinguished. "Cannot reach tgju", "tgju answered with a status", "the page will not parse", "the page parsed but was empty" and "no such instrument" are five different errors, because they call for five different reactions.

One dependency. golang.org/x/net/html. No web framework — net/http's router does methods and path variables. No metrics client — the Prometheus text format is a few lines of fmt.Fprintf. No YAML parser — the OpenAPI document is checked structurally by a tool in internal/cmd/checkspec. make deps-check keeps it that way.

The HTTP layer is a handler, not a program. Nothing in server/ opens a socket or reads the environment. That is what lets it be mounted inside somebody else's service.

Testing

make test     # race detector
make cover    # coverage
make fuzz     # fuzz the number parsers
make live     # hit the real tgju.org

Tests run against saved tgju pages in internal/fixture/testdata, shared by the parser, client, server and CLI suites, so all four agree on what tgju looks like. Refresh them with make fixtures; the diff is the review.

Because the fixtures are frozen, the suite can be green while the live site has moved on. That gap is covered by a CI job that fetches all three boards from the real tgju.org on every push to main. It is allowed to fail — a red build because a third party is down helps nobody — and it is the canary that goes off first when tgju redesigns a page.

Development

git clone https://github.com/amiranmanesh/tgju-api-go
cd tgju-api-go
make ci       # lint, dependency policy, spec check, tests
make run      # serve on :8080
make help     # every target

See CONTRIBUTING.md.

Compatibility with the Python API

/api/price/currency and /api/price/gold return exactly what BlackIQ/tgju-api returned. /api/price/coin extends the same shape to coins.

One difference: absent values are "" rather than null. If your client checks is None, change it to a falsiness check — that is the whole migration.

The migration guide has the details, including what the request-log database was for and why there isn't one.

Project layout

.                      the library: Client, Snapshot, Item, options, errors
├── server/            the HTTP API as an http.Handler, plus openapi.yaml
├── cmd/tgju/          the binary: serve, get, item, watch, markets, version
├── internal/
│   ├── scrape/        the HTML parser — everything fragile lives here
│   ├── dom/           a query layer over golang.org/x/net/html
│   ├── numfmt/        Persian digits and number parsing
│   ├── fixture/       saved tgju pages, shared by every test suite
│   └── cmd/           maintenance tools: fixtures, checkspec, healthcheck
├── examples/          basic, embed (both halves at once), alert
├── docs/              the GitHub Pages site
└── wiki/              the wiki, published by a workflow

Credits

The endpoint shape, the field names and the choice of boards come from BlackIQ/tgju-api by @BlackIQ. The status, low_price and high_price fields were @fatehi-develop's idea. This is a reimplementation rather than a fork, and the compatibility layer exists so that work is not wasted.

The prices themselves belong to tgju.org.

Licence and fair use

MIT.

This project is not affiliated with, endorsed by, or connected to tgju.org. It reads their public pages and makes no claim about the accuracy of the data it relays — if tgju is wrong, this is wrong.

If you run it in front of real users: keep the cache on, do not remove the rate limiter, and read tgju's terms of service first. A scraper that is polite costs its source almost nothing; one that is not gets everybody blocked.

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")
}

Index

Examples

Constants

View Source
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.

View Source
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.

View Source
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

View Source
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.

View Source
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

func (a Amount) IsZero() bool

IsZero reports whether the amount carries no value at all, which is how an empty table cell arrives.

func (Amount) Rial

func (a Amount) Rial() int64

Rial returns the value rounded to whole rials.

func (Amount) String

func (a Amount) String() 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.

func (Amount) Toman

func (a Amount) Toman() float64

Toman returns the value in toman, the unit Iranians actually quote prices in. One toman is ten rials.

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.

func (Change) Signum

func (c Change) Signum() int

Signum returns +1 when the price rose, -1 when it fell and 0 when tgju published no direction. It is the bridge between the site's unsigned numbers and arithmetic that needs a sign.

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

func New(opts ...Option) *Client

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
}
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))
}

func (*Client) BaseURL

func (c *Client) BaseURL() string

BaseURL returns the site the client reads from.

func (*Client) CacheTTL

func (c *Client) CacheTTL() time.Duration

CacheTTL returns how long snapshots are reused. Zero means the cache is off.

func (*Client) Coin

func (c *Client) Coin(ctx context.Context) (Snapshot, error)

Coin returns the Bahar Azadi coin board.

func (*Client) Currency

func (c *Client) Currency(ctx context.Context) (Snapshot, error)

Currency returns the foreign exchange board.

func (*Client) Fetch

func (c *Client) Fetch(ctx context.Context, m Market) (Snapshot, error)

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

func (c *Client) FetchAll(ctx context.Context, markets ...Market) (map[Market]Snapshot, error)

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) Gold

func (c *Client) Gold(ctx context.Context) (Snapshot, error)

Gold returns the gold, silver and mesghal board.

func (*Client) Invalidate

func (c *Client) Invalidate(markets ...Market)

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

func (c *Client) Item(ctx context.Context, key string, markets ...Market) (Item, error)

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)
	}
}

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)
	}
}

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

func (*Error) Temporary

func (e *Error) Temporary() bool

Temporary reports whether retrying the same call later could plausibly succeed. Parse failures and unknown markets are permanent; transport errors, rate limits and server errors are not.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap exposes the wrapped error to errors.Is and errors.As.

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.

func (Item) Spread

func (i Item) Spread() float64

Spread returns the distance between the daily high and the daily low. It is zero when either extreme is missing.

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 Markets

func Markets() []Market

Markets returns the supported markets in a stable order.

func ParseMarket

func ParseMarket(s string) (Market, error)

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) Label

func (m Market) Label() string

Label returns the Persian name of the board, or "" for an unknown market.

func (Market) Path

func (m Market) Path() string

Path returns the path of the market page relative to the site root, or "" for an unknown market.

func (Market) String

func (m Market) String() string

String implements fmt.Stringer.

func (Market) URL

func (m Market) URL() string

URL returns the absolute address of the market page on the public site.

func (Market) Valid

func (m Market) Valid() bool

Valid reports whether the market is one this library knows how to fetch.

type Option

type Option func(*config)

Option configures a Client. Options are applied in order, so a later one wins.

func WithBaseURL

func WithBaseURL(u string) Option

WithBaseURL points the client at another host. The trailing slash is optional. It exists for mirrors, corporate proxies and, above all, tests.

func WithCacheTTL

func WithCacheTTL(d time.Duration) Option

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

func WithClock(now func() time.Time) Option

WithClock replaces the source of time. Tests use it to drive cache expiry without sleeping.

func WithHTTPClient

func WithHTTPClient(d Doer) Option

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

func WithHeader(name, value string) Option

WithHeader adds a header to every outgoing request. Call it repeatedly to set several; a repeated name replaces the previous value.

func WithLogger

func WithLogger(l *slog.Logger) Option

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

func WithMaxBodyBytes(n int64) Option

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

func WithTimeout(d time.Duration) Option

WithTimeout bounds one call to Client.Fetch, retries and backoff included. Zero or less restores DefaultTimeout.

func WithUserAgent

func WithUserAgent(ua string) Option

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

func (s Snapshot) All() iter.Seq[Item]

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)
	}
}

func (Snapshot) Category

func (s Snapshot) Category(title string) (Category, bool)

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) IsEmpty

func (s Snapshot) IsEmpty() bool

IsEmpty reports whether the snapshot carries no items.

func (Snapshot) Items

func (s Snapshot) Items() []Item

Items flattens the snapshot into a freshly allocated slice. Prefer [All] when you only need to walk the items once.

func (Snapshot) Keys

func (s Snapshot) Keys() []string

Keys returns the key of every item, in page order.

func (Snapshot) Len

func (s Snapshot) Len() int

Len returns the number of items across every category.

func (Snapshot) Lookup

func (s Snapshot) Lookup(key string) (Item, bool)

Lookup returns the item with the given key.

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.

func (Status) String

func (s Status) String() string

String implements fmt.Stringer.

func (Status) Valid

func (s Status) Valid() bool

Valid reports whether s is a direction tgju actually publishes.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL