├── res └── call.png ├── go.mod ├── example └── stcp.go ├── README.md ├── search └── search.go ├── go.sum └── api.go /res/call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sug0/go-stcp-bus-api/HEAD/res/call.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sug0/go-stcp-bus-api 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/coocood/freecache v1.1.1 7 | github.com/valyala/bytebufferpool v1.0.0 8 | github.com/valyala/fasthttp v1.34.0 9 | github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 10 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f 11 | ) 12 | -------------------------------------------------------------------------------- /example/stcp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/valyala/fasthttp" 7 | "github.com/sug0/go-stcp-bus-api" 8 | ) 9 | 10 | func main() { 11 | // * Requests are made to http://localhost:8080/ 12 | // * Bus stop codes can be found here http://www.stcp.pt/pt/viajar/linhas/ 13 | panic(fasthttp.ListenAndServe(os.Args[1], stcpbusapi.Handler)) 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚌🚏 API não oficial dos autocarros da STCP 2 | 3 | Este código em Go permite obter o tempo real de espera dos autocarros da 4 | STCP no Porto, sob a forma de um leve serviço HTTP. 5 | 6 | Uma vez que assenta sob um API não oficial (se é que se pode chamar API 7 | sequer lol), é possível que deixe de funcionar a qualquer momento... 8 | Para que se possa obter um API oficial, por favor apelem à câmara do 9 | Porto para que este seja disponibilizado para livre uso dos programadores! 10 | 11 | 12 | # Uso 13 | 14 | ```go 15 | fasthttp.ListenAndServe(":8080", stcpbusapi.Handler) 16 | ``` 17 | 18 | ![](res/call.png) 19 | -------------------------------------------------------------------------------- /search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/url" 6 | "net/http" 7 | "encoding/json" 8 | "unsafe" 9 | ) 10 | 11 | type BusStop struct { 12 | Code string `json:"code"` 13 | Name string `json:"name"` 14 | Zone string `json:"zone"` 15 | Location Location `json:"geomdesc"` 16 | Lines []Line `json:"lines"` 17 | } 18 | 19 | type Line struct { 20 | Code string `json:"code"` 21 | Description string `json:"description"` 22 | } 23 | 24 | type Location struct { 25 | Lat float64 `json:"lat"` 26 | Lng float64 `json:"lng"` 27 | } 28 | 29 | type geomdesc struct { 30 | Coordinates [2]float64 `json:"coordinates"` 31 | } 32 | 33 | // Looks up a list of bus stops given a query string. 34 | func BusStops(q string) ([]BusStop, error) { 35 | const ( 36 | base = "https://www.stcp.pt/pt/itinerarium/callservice.php?action=srchstoplines&stopname=" 37 | ) 38 | rsp, err := http.Get(base + url.QueryEscape(q)) 39 | if err != nil { 40 | return nil, err 41 | } 42 | defer rsp.Body.Close() 43 | data, err := ioutil.ReadAll(rsp.Body) 44 | if err != nil { 45 | return nil, err 46 | } 47 | var stops []BusStop 48 | err = json.Unmarshal(data, &stops) 49 | if err != nil { 50 | return nil, err 51 | } 52 | return stops, nil 53 | } 54 | 55 | func (l *Location) UnmarshalJSON(data []byte) error { 56 | var b []byte 57 | err := json.Unmarshal(data, (*string)(unsafe.Pointer(&b))) 58 | if err != nil { 59 | return err 60 | } 61 | var g geomdesc 62 | err = json.Unmarshal(b[:len(b):len(b)], &g) 63 | if err != nil { 64 | return err 65 | } 66 | l.Lat = g.Coordinates[0] 67 | l.Lng = g.Coordinates[1] 68 | return nil 69 | } 70 | 71 | func (l *Location) MarshalJSON() ([]byte, error) { 72 | g := geomdesc{ 73 | Coordinates: [2]float64{l.Lat, l.Lng}, 74 | } 75 | b, err := json.Marshal(g) 76 | if err != nil { 77 | panic(err) 78 | } 79 | s := *(*string)(unsafe.Pointer(&b)) 80 | return json.Marshal(s) 81 | } 82 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= 2 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 3 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= 4 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 5 | github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= 6 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 7 | github.com/coocood/freecache v1.1.1 h1:uukNF7QKCZEdZ9gAV7WQzvh0SbjwdMF6m3x3rxEkaPc= 8 | github.com/coocood/freecache v1.1.1/go.mod h1:OKrEjkGVoxZhyWAJoeFi5BMLUJm2Tit0kpGkIr7NGYY= 9 | github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= 10 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 11 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= 12 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 13 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 14 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 15 | github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= 16 | github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= 17 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 18 | github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 h1:6Ju8pZBYFTN9FaV/JvNBiIHcsgEmP4z4laciqjfjY8E= 19 | github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945/go.mod h1:4vRFPPNYllgCacoj+0FoKOjTW68rUhEfqPLiEJaK2w8= 20 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 21 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 22 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= 23 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 24 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 30 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 31 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 32 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 33 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 34 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package stcpbusapi 2 | 3 | import ( 4 | "fmt" 5 | "bytes" 6 | 7 | "golang.org/x/net/html" 8 | "golang.org/x/net/html/atom" 9 | "github.com/yhat/scrape" 10 | "github.com/valyala/fasthttp" 11 | "github.com/valyala/bytebufferpool" 12 | "github.com/coocood/freecache" 13 | ) 14 | 15 | // The fasthttp.RequestHandler for this API endpoint. Responses 16 | // are in JSON, arguments are taken from the request 17 | // path. Example: http://localhost:8080/BCM1 18 | var Handler fasthttp.RequestHandler 19 | 20 | const ( 21 | errSTCPOffline = -1 - iota 22 | errNoParse 23 | errNoBuses 24 | ) 25 | 26 | var errHandleFuns = [...]func(*fasthttp.RequestCtx){ 27 | func(ctx *fasthttp.RequestCtx) {ctx.WriteString(`{"erro":"O API da STCP est\u00e1 offline."}`)}, 28 | func(ctx *fasthttp.RequestCtx) {ctx.WriteString(`{"erro":"O API respondeu com HTML inv\u00e1lido."}`)}, 29 | func(ctx *fasthttp.RequestCtx) {ctx.WriteString(`{"carros":[]}`)}, 30 | } 31 | 32 | var cache *freecache.Cache 33 | 34 | // 25 seconds cache timeout 35 | const cacheTimeout = 25 36 | 37 | func init() { 38 | Handler = stcpHandler 39 | cache = freecache.NewCache(1 << 14) 40 | } 41 | 42 | func stcpHandler(ctx *fasthttp.RequestCtx) { 43 | stop := ctx.Path() 44 | 45 | // no bus stop code in path 46 | if bytes.Equal(stop, []byte("/")) { 47 | ctx.WriteString(`{"erro":"Nenhum c\u00f3digo de paragem encontrado no caminho."}`) 48 | return 49 | } 50 | 51 | // trim outer slashes 52 | stop = stop[1:] 53 | n := len(stop) - 1 54 | 55 | if stop[n] == '/' { 56 | stop = stop[:n] 57 | } 58 | 59 | // try to fetch from cache 60 | rsp, err := cache.Get(stop) 61 | if err == nil { 62 | // cache hit! 63 | ctx.Write(rsp) 64 | return 65 | } 66 | 67 | // download bus info 68 | buses, errno := getBuses(stop) 69 | 70 | // error handling 71 | if errno < 0 { 72 | errHandleFuns[-errno - 1](ctx) 73 | return 74 | } 75 | 76 | // skip table headers 77 | buses = buses[1:] 78 | n = len(buses) - 1 79 | 80 | var buf bytes.Buffer 81 | 82 | // write beginning 83 | buf.WriteString(`{"carros":[`) 84 | 85 | for i := 0; i < n; i++ { 86 | fmtBus(&buf, true, buses[i]) 87 | } 88 | 89 | // write end 90 | fmtBus(&buf, false, buses[n]) 91 | buf.WriteString(`]}`) 92 | 93 | // save to cache, and write response 94 | rsp = buf.Bytes() 95 | cache.Set(stop, rsp, cacheTimeout) 96 | ctx.Write(rsp) 97 | } 98 | 99 | func fmtBus(buf *bytes.Buffer, comma bool, bus *html.Node) { 100 | // print car 101 | td := bus.FirstChild.NextSibling 102 | fmt.Fprintf(buf, `{"carro":"%s",`, scrape.Text(td)) 103 | 104 | // print time 105 | td = td.NextSibling.NextSibling 106 | fmt.Fprintf(buf, `"hora":"%s",`, scrape.Text(td)) 107 | 108 | // print await time 109 | td = td.NextSibling.NextSibling 110 | fmt.Fprintf(buf, `"espera":"%s"}`, scrape.Text(td)) 111 | 112 | if comma { 113 | buf.WriteString(",") 114 | } 115 | } 116 | 117 | func getBuses(code []byte) ([]*html.Node, int) { 118 | buf, body, errno := downloadHTML(code) 119 | if errno != 0 { 120 | return nil, errno 121 | } 122 | defer bytebufferpool.Put(buf) 123 | 124 | root, err := html.Parse(bytes.NewReader(body)) 125 | if err != nil { 126 | return nil, errNoParse 127 | } 128 | 129 | // parse rows 130 | rows := scrape.FindAll(root, matchRow) 131 | 132 | // no rows found -- no buses in the next 60 min :( 133 | if len(rows) == 0 { 134 | return nil, errNoBuses 135 | } 136 | 137 | return rows, 0 138 | } 139 | 140 | func downloadHTML(code []byte) (*bytebufferpool.ByteBuffer, []byte, int) { 141 | buf := bytebufferpool.Get() 142 | path := "https://www.stcp.pt/pt/itinerarium/soapclient.php?codigo="+string(code) 143 | _, rsp, err := fasthttp.Get(buf.B, path) 144 | if err != nil { 145 | return nil, nil, errSTCPOffline 146 | } 147 | return buf, rsp, 0 148 | } 149 | 150 | func matchRow(n *html.Node) bool { 151 | return n.DataAtom == atom.Tr 152 | } 153 | --------------------------------------------------------------------------------