feat: SlickVPN Support (#961)
- `internal/updater/html` package - Add unit tests for slickvpn updating code - Change shared html package to be more share-able - Split html utilities in multiple files - Fix processing .ovpn files with prefix space Authored by @Rohaq Co-authored-by: Quentin McGaw <quentin.mcgaw@gmail.com>
This commit is contained in:
181
internal/provider/slickvpn/updater/website.go
Normal file
181
internal/provider/slickvpn/updater/website.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
htmlutils "github.com/qdm12/gluetun/internal/updater/html"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func fetchServers(ctx context.Context, client *http.Client) (
|
||||
hostToData map[string]serverData, err error) {
|
||||
rootNode, err := fetchHTML(ctx, client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching HTML code: %w", err)
|
||||
}
|
||||
|
||||
hostToData, err = parseHTML(rootNode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing HTML: %w", err)
|
||||
}
|
||||
|
||||
return hostToData, nil
|
||||
}
|
||||
|
||||
var ErrHTTPStatusCode = errors.New("HTTP status code is not OK")
|
||||
|
||||
func fetchHTML(ctx context.Context, client *http.Client) (rootNode *html.Node, err error) {
|
||||
const url = "https://www.slickvpn.com/locations/"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d %s",
|
||||
ErrHTTPStatusCode, response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
rootNode, err = html.Parse(response.Body)
|
||||
if err != nil {
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("parsing HTML code: %w", err)
|
||||
}
|
||||
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing response body: %w", err)
|
||||
}
|
||||
|
||||
return rootNode, nil
|
||||
}
|
||||
|
||||
type serverData struct {
|
||||
ovpnURL string
|
||||
country string
|
||||
region string
|
||||
city string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrLocationTableNotFound = errors.New("HTML location table node not found")
|
||||
ErrTbodyNotFound = errors.New("HTML tbody node not found")
|
||||
ErrExtractOpenVPNURL = errors.New("failed extracting OpenVPN URL")
|
||||
)
|
||||
|
||||
func parseHTML(rootNode *html.Node) (hostToData map[string]serverData, err error) {
|
||||
locationTableNode := htmlutils.BFS(rootNode, matchLocationTable)
|
||||
if locationTableNode == nil {
|
||||
return nil, htmlutils.WrapError(ErrLocationTableNotFound, rootNode)
|
||||
}
|
||||
|
||||
tBodyNode := htmlutils.BFS(locationTableNode, matchTbody)
|
||||
if tBodyNode == nil {
|
||||
return nil, htmlutils.WrapError(ErrTbodyNotFound, rootNode)
|
||||
}
|
||||
|
||||
rowNodes := htmlutils.DirectChildren(tBodyNode, matchTr)
|
||||
hostToData = make(map[string]serverData, len(rowNodes))
|
||||
|
||||
for _, rowNode := range rowNodes {
|
||||
hostname, data, err := parseRowNode(rowNode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing row node: %w", err)
|
||||
}
|
||||
hostToData[hostname] = data
|
||||
}
|
||||
|
||||
return hostToData, nil
|
||||
}
|
||||
|
||||
func parseRowNode(rowNode *html.Node) (hostname string, data serverData, err error) {
|
||||
columnIndex := 0
|
||||
const (
|
||||
columnIndexContinent = 0
|
||||
columnIndexCountry = 1
|
||||
columnIndexCity = 2
|
||||
columnIndexConfig = 3
|
||||
)
|
||||
for cellNode := rowNode.FirstChild; cellNode != nil; cellNode = cellNode.NextSibling {
|
||||
if cellNode.FirstChild == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch columnIndex {
|
||||
case columnIndexContinent:
|
||||
data.region = cellNode.FirstChild.Data
|
||||
case columnIndexCountry:
|
||||
data.country = cellNode.FirstChild.Data
|
||||
case columnIndexCity:
|
||||
data.city = cellNode.FirstChild.Data
|
||||
case columnIndexConfig:
|
||||
linkNodes := htmlutils.DirectChildren(cellNode, matchA)
|
||||
for _, linkNode := range linkNodes {
|
||||
if linkNode.FirstChild.Data != "OpenVPN" {
|
||||
continue
|
||||
}
|
||||
|
||||
data.ovpnURL = htmlutils.Attribute(linkNode, "href")
|
||||
if data.ovpnURL == "" {
|
||||
return "", data, htmlutils.WrapError(ErrExtractOpenVPNURL, linkNode)
|
||||
}
|
||||
|
||||
hostname, err = extractHostnameFromURL(data.ovpnURL)
|
||||
if err != nil {
|
||||
return "", data, fmt.Errorf("extracting hostname from url: %w", err)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
columnIndex++
|
||||
if columnIndex == columnIndexConfig+1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return hostname, data, nil
|
||||
}
|
||||
|
||||
func matchLocationTable(rootNode *html.Node) (match bool) {
|
||||
return htmlutils.MatchID("location-table")(rootNode)
|
||||
}
|
||||
|
||||
func matchTbody(locationTableNode *html.Node) (match bool) {
|
||||
return htmlutils.MatchData("tbody")(locationTableNode)
|
||||
}
|
||||
|
||||
func matchTr(tbodyNode *html.Node) (match bool) {
|
||||
return htmlutils.MatchData("tr")(tbodyNode)
|
||||
}
|
||||
|
||||
func matchA(cellNode *html.Node) (match bool) {
|
||||
return htmlutils.MatchData("a")(cellNode)
|
||||
}
|
||||
|
||||
var serverNameRegex = regexp.MustCompile(`^.+\/(?P<serverName>.+)\.ovpn$`)
|
||||
|
||||
var (
|
||||
ErrExtractHostnameFromURL = errors.New("cannot extract hostname from url")
|
||||
)
|
||||
|
||||
func extractHostnameFromURL(url string) (hostname string, err error) {
|
||||
matches := serverNameRegex.FindStringSubmatch(url)
|
||||
const minMatches = 2
|
||||
if len(matches) < minMatches {
|
||||
return "", fmt.Errorf("%w: %s has less than 2 matches for %s",
|
||||
ErrExtractHostnameFromURL, url, serverNameRegex)
|
||||
}
|
||||
hostname = matches[1]
|
||||
return hostname, nil
|
||||
}
|
||||
Reference in New Issue
Block a user