mirror of
https://github.com/OpenStaticFish/MicroServices.git
synced 2026-10-13 06:35:12 +00:00
Route country scrapes through Webshare
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
WEBSHARE_API_KEY=
|
||||||
|
WEBSHARE_PROXY_USERNAME=
|
||||||
|
WEBSHARE_PROXY_PASSWORD=
|
||||||
@@ -25,10 +25,17 @@ docker_build('scraper', './services/scraper')
|
|||||||
|
|
||||||
k8s_yaml('./k8s/scraper.yaml')
|
k8s_yaml('./k8s/scraper.yaml')
|
||||||
|
|
||||||
|
local_resource(
|
||||||
|
'webshare-secret',
|
||||||
|
cmd='set -a; [ ! -f .env ] || . ./.env; set +a; if [ -n "$WEBSHARE_API_KEY" ] && [ -n "$WEBSHARE_PROXY_USERNAME" ] && [ -n "$WEBSHARE_PROXY_PASSWORD" ]; then kubectl create secret generic webshare-api --from-literal=api-key="$WEBSHARE_API_KEY" --from-literal=proxy-username="$WEBSHARE_PROXY_USERNAME" --from-literal=proxy-password="$WEBSHARE_PROXY_PASSWORD" --dry-run=client -o yaml | kubectl apply -f -; else echo "Webshare runtime config missing; country scraping unavailable"; fi',
|
||||||
|
labels=['utility'],
|
||||||
|
)
|
||||||
|
|
||||||
# Watch source files for live reload
|
# Watch source files for live reload
|
||||||
k8s_resource(
|
k8s_resource(
|
||||||
workload='scraper-deployment',
|
workload='scraper-deployment',
|
||||||
port_forwards='8080:8080',
|
port_forwards='8080:8080',
|
||||||
|
resource_deps=['webshare-secret'],
|
||||||
labels=['scraper'],
|
labels=['scraper'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,15 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "🐟 OpenStaticFish MicroServices Dev Shell"
|
if [ -f .env ]; then
|
||||||
echo "-----------------------------------------"
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "''${OPENSTATICFISH_QUIET_SHELL:-}" != "1" ]; then
|
||||||
|
echo "🐟 OpenStaticFish MicroServices Dev Shell"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
|
||||||
# --- Tilt Commands ---
|
# --- Tilt Commands ---
|
||||||
# Create/select the local Kind cluster first:
|
# Create/select the local Kind cluster first:
|
||||||
@@ -87,14 +94,15 @@
|
|||||||
# List running containers:
|
# List running containers:
|
||||||
# docker ps
|
# docker ps
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Available commands:"
|
echo "Available commands:"
|
||||||
echo " setup-kind - Create/select local Kind cluster"
|
echo " setup-kind - Create/select local Kind cluster"
|
||||||
echo " tilt up - Start dev environment"
|
echo " tilt up - Start dev environment"
|
||||||
echo " tilt down - Stop dev environment"
|
echo " tilt down - Stop dev environment"
|
||||||
echo " kubectl ... - Interact with k8s cluster"
|
echo " kubectl ... - Interact with k8s cluster"
|
||||||
echo " docker ... - Interact with containers"
|
echo " docker ... - Interact with containers"
|
||||||
echo ""
|
echo ""
|
||||||
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: PORT
|
- name: PORT
|
||||||
value: "8080"
|
value: "8080"
|
||||||
|
- name: WEBSHARE_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: webshare-api
|
||||||
|
key: api-key
|
||||||
|
optional: true
|
||||||
|
- name: WEBSHARE_PROXY_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: webshare-api
|
||||||
|
key: proxy-username
|
||||||
|
optional: true
|
||||||
|
- name: WEBSHARE_PROXY_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: webshare-api
|
||||||
|
key: proxy-password
|
||||||
|
optional: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "64Mi"
|
memory: "64Mi"
|
||||||
|
|||||||
+34
-4
@@ -2,18 +2,20 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --- OpenStaticFish Scraper Test Script ---
|
# --- OpenStaticFish Scraper Test Script ---
|
||||||
# Usage: ./scripts/test-scrape <url> [md|json]
|
# Usage: ./scripts/test-scrape <url> [md|json] [country]
|
||||||
# url The URL to scrape
|
# url The URL to scrape
|
||||||
# format Output format: md or json (default: json)
|
# format Output format: md or json (default: json)
|
||||||
|
# country Optional Webshare proxy country code (for example: us, gb, nl, pl)
|
||||||
|
|
||||||
SCRAPE_URL="${1:-}"
|
SCRAPE_URL="${1:-}"
|
||||||
FORMAT="${2:-json}"
|
FORMAT="${2:-json}"
|
||||||
|
EGRESS="${3:-}"
|
||||||
API_ENDPOINT="http://localhost:8080/scrape"
|
API_ENDPOINT="http://localhost:8080/scrape"
|
||||||
|
|
||||||
if [[ -z "$SCRAPE_URL" ]]; then
|
if [[ -z "$SCRAPE_URL" ]]; then
|
||||||
echo "Error: URL is required"
|
echo "Error: URL is required"
|
||||||
echo "Usage: $0 <url> [md|json]"
|
echo "Usage: $0 <url> [md|json] [country]"
|
||||||
echo "Example: $0 https://example.com md"
|
echo "Example: $0 https://example.com md gb"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -24,12 +26,40 @@ fi
|
|||||||
|
|
||||||
echo "Scraping: $SCRAPE_URL"
|
echo "Scraping: $SCRAPE_URL"
|
||||||
echo "Format: $FORMAT"
|
echo "Format: $FORMAT"
|
||||||
|
if [[ -n "$EGRESS" ]]; then
|
||||||
|
echo "Country: $EGRESS"
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
if [[ -n "${WEBSHARE_API_KEY:-}" && -n "${WEBSHARE_PROXY_USERNAME:-}" && -n "${WEBSHARE_PROXY_PASSWORD:-}" ]]; then
|
||||||
|
if command -v kubectl >/dev/null 2>&1; then
|
||||||
|
kubectl create secret generic webshare-api \
|
||||||
|
--from-literal=api-key="$WEBSHARE_API_KEY" \
|
||||||
|
--from-literal=proxy-username="$WEBSHARE_PROXY_USERNAME" \
|
||||||
|
--from-literal=proxy-password="$WEBSHARE_PROXY_PASSWORD" \
|
||||||
|
--dry-run=client \
|
||||||
|
-o yaml | kubectl apply -f - >/dev/null
|
||||||
|
kubectl rollout restart deployment/scraper-deployment >/dev/null 2>&1 || true
|
||||||
|
kubectl rollout status deployment/scraper-deployment --timeout=120s >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
OPENSTATICFISH_QUIET_SHELL=1 nix --quiet --no-warn-dirty --option eval-cache false develop --command bash -c 'kubectl create secret generic webshare-api --from-literal=api-key="$WEBSHARE_API_KEY" --from-literal=proxy-username="$WEBSHARE_PROXY_USERNAME" --from-literal=proxy-password="$WEBSHARE_PROXY_PASSWORD" --dry-run=client -o yaml | kubectl apply -f - >/dev/null; kubectl rollout restart deployment/scraper-deployment >/dev/null 2>&1 || true; kubectl rollout status deployment/scraper-deployment --timeout=120s >/dev/null 2>&1 || true'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
echo "---"
|
echo "---"
|
||||||
|
|
||||||
|
PAYLOAD="{\"url\": \"$SCRAPE_URL\", \"format\": \"$FORMAT\"}"
|
||||||
|
if [[ -n "$EGRESS" ]]; then
|
||||||
|
PAYLOAD="{\"url\": \"$SCRAPE_URL\", \"format\": \"$FORMAT\", \"country\": \"$EGRESS\"}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Send request
|
# Send request
|
||||||
RESPONSE=$(curl -s -X POST "$API_ENDPOINT" \
|
RESPONSE=$(curl -s -X POST "$API_ENDPOINT" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"url\": \"$SCRAPE_URL\", \"format\": \"$FORMAT\"}")
|
-d "$PAYLOAD")
|
||||||
|
|
||||||
if [[ "$FORMAT" == "md" ]]; then
|
if [[ "$FORMAT" == "md" ]]; then
|
||||||
# Raw markdown output
|
# Raw markdown output
|
||||||
|
|||||||
+223
-11
@@ -3,10 +3,14 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
@@ -15,8 +19,9 @@ import (
|
|||||||
|
|
||||||
// ScrapeRequest represents the incoming request body
|
// ScrapeRequest represents the incoming request body
|
||||||
type ScrapeRequest struct {
|
type ScrapeRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Format string `json:"format"` // "md" or "json"
|
Format string `json:"format"` // "md" or "json"
|
||||||
|
Country string `json:"country"` // optional Webshare proxy country code, for example "us" or "gb"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScrapeResponse represents the response data
|
// ScrapeResponse represents the response data
|
||||||
@@ -29,6 +34,7 @@ type ScrapeResponse struct {
|
|||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
Links []string `json:"links,omitempty"`
|
Links []string `json:"links,omitempty"`
|
||||||
Images []string `json:"images,omitempty"`
|
Images []string `json:"images,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
ScrapedAt time.Time `json:"scraped_at"`
|
ScrapedAt time.Time `json:"scraped_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,31 @@ type ErrorResponse struct {
|
|||||||
Details string `json:"details,omitempty"`
|
Details string `json:"details,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webshareProxyCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
type webshareProxy struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ProxyAddress string `json:"proxy_address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
CountryCode string `json:"country_code"`
|
||||||
|
CityName string `json:"city_name"`
|
||||||
|
LastVerification string `json:"last_verification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webshareProxyListResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Results []webshareProxy `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var webshareCache = struct {
|
||||||
|
sync.Mutex
|
||||||
|
proxies []webshareProxy
|
||||||
|
fetchedAt time.Time
|
||||||
|
}{}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
@@ -82,8 +113,14 @@ func handleScrape(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
country := normalizeCountryCode(req.Country)
|
||||||
|
if err := validateCountry(country); err != nil {
|
||||||
|
respondWithError(w, http.StatusBadRequest, "Invalid country", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
req.URL = normalizeURL(req.URL)
|
req.URL = normalizeURL(req.URL)
|
||||||
result, err := scrapeURL(req.URL, format)
|
result, err := scrapeURL(req.URL, format, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondWithError(w, http.StatusInternalServerError, "Scraping failed", err.Error())
|
respondWithError(w, http.StatusInternalServerError, "Scraping failed", err.Error())
|
||||||
return
|
return
|
||||||
@@ -98,7 +135,23 @@ func handleScrape(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrapeURL(url, format string) (*ScrapeResponse, error) {
|
func scrapeURL(url, format string, country string) (*ScrapeResponse, error) {
|
||||||
|
if country != "" {
|
||||||
|
proxy, err := selectWebshareProxy(country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transport, err := webshareProxyTransport(proxy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scrapeURLWithTransport(url, format, country, transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrapeURLWithTransport(url, format, country, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrapeURLWithTransport(url, format string, country string, transport *http.Transport) (*ScrapeResponse, error) {
|
||||||
var htmlContent string
|
var htmlContent string
|
||||||
var title string
|
var title string
|
||||||
var description string
|
var description string
|
||||||
@@ -107,8 +160,13 @@ func scrapeURL(url, format string) (*ScrapeResponse, error) {
|
|||||||
metadata := make(map[string]string)
|
metadata := make(map[string]string)
|
||||||
|
|
||||||
c := colly.NewCollector(
|
c := colly.NewCollector(
|
||||||
colly.UserAgent("OpenStaticFish-Scraper/1.0"),
|
colly.UserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"),
|
||||||
)
|
)
|
||||||
|
c.SetRequestTimeout(30 * time.Second)
|
||||||
|
|
||||||
|
if transport != nil {
|
||||||
|
c.WithTransport(transport)
|
||||||
|
}
|
||||||
|
|
||||||
c.OnResponse(func(r *colly.Response) {
|
c.OnResponse(func(r *colly.Response) {
|
||||||
htmlContent = string(r.Body)
|
htmlContent = string(r.Body)
|
||||||
@@ -173,6 +231,7 @@ func scrapeURL(url, format string) (*ScrapeResponse, error) {
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
Links: deduplicate(links),
|
Links: deduplicate(links),
|
||||||
Images: deduplicate(images),
|
Images: deduplicate(images),
|
||||||
|
Country: country,
|
||||||
ScrapedAt: time.Now().UTC(),
|
ScrapedAt: time.Now().UTC(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -191,6 +250,159 @@ func normalizeURL(rawURL string) string {
|
|||||||
return "https://" + rawURL
|
return "https://" + rawURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCountry(country string) error {
|
||||||
|
if country == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(country) != 2 || country[0] < 'a' || country[0] > 'z' || country[1] < 'a' || country[1] > 'z' {
|
||||||
|
return fmt.Errorf("country must be a two-letter country code, for example us, gb, nl, pl")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCountryCode(country string) string {
|
||||||
|
country = strings.ToLower(strings.TrimSpace(country))
|
||||||
|
if country == "uk" {
|
||||||
|
return "gb"
|
||||||
|
}
|
||||||
|
return country
|
||||||
|
}
|
||||||
|
|
||||||
|
func webshareProxyTransport(proxy webshareProxy) (*http.Transport, error) {
|
||||||
|
username := proxy.Username
|
||||||
|
password := proxy.Password
|
||||||
|
if username == "" {
|
||||||
|
username = os.Getenv("WEBSHARE_PROXY_USERNAME")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
password = os.Getenv("WEBSHARE_PROXY_PASSWORD")
|
||||||
|
}
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return nil, fmt.Errorf("Webshare proxy credentials are missing for selected proxy")
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL := &url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: fmt.Sprintf("%s:%d", proxy.ProxyAddress, proxy.Port),
|
||||||
|
User: url.UserPassword(username, password),
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(proxyURL.String()); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to configure Webshare proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectWebshareProxy(country string) (webshareProxy, error) {
|
||||||
|
proxies, err := getWebshareProxies(false)
|
||||||
|
if err != nil {
|
||||||
|
return webshareProxy{}, err
|
||||||
|
}
|
||||||
|
if proxy, ok := findWebshareProxy(proxies, country); ok {
|
||||||
|
return proxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies, err = getWebshareProxies(true)
|
||||||
|
if err != nil {
|
||||||
|
return webshareProxy{}, err
|
||||||
|
}
|
||||||
|
if proxy, ok := findWebshareProxy(proxies, country); ok {
|
||||||
|
return proxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return webshareProxy{}, fmt.Errorf("no valid Webshare proxy available for country %q; available countries: %s", country, strings.Join(availableWebshareCountries(proxies), ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWebshareProxy(proxies []webshareProxy, country string) (webshareProxy, bool) {
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if proxy.Valid && strings.EqualFold(proxy.CountryCode, country) {
|
||||||
|
return proxy, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return webshareProxy{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWebshareProxies(forceRefresh bool) ([]webshareProxy, error) {
|
||||||
|
webshareCache.Lock()
|
||||||
|
defer webshareCache.Unlock()
|
||||||
|
|
||||||
|
if !forceRefresh && len(webshareCache.proxies) > 0 && time.Since(webshareCache.fetchedAt) < webshareProxyCacheTTL {
|
||||||
|
return webshareCache.proxies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies, err := fetchWebshareProxies()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webshareCache.proxies = proxies
|
||||||
|
webshareCache.fetchedAt = time.Now()
|
||||||
|
return proxies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWebshareProxies() ([]webshareProxy, error) {
|
||||||
|
apiKey := os.Getenv("WEBSHARE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("WEBSHARE_API_KEY is required when country is set")
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxies []webshareProxy
|
||||||
|
listURL := "https://proxy.webshare.io/api/v2/proxy/list/?mode=direct&page=1&page_size=100"
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
|
||||||
|
for listURL != "" {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, listURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create Webshare request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Token "+apiKey)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch Webshare proxies: %w", err)
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read Webshare response: %w", readErr)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("Webshare proxy list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result webshareProxyListResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse Webshare proxy list: %w", err)
|
||||||
|
}
|
||||||
|
proxies = append(proxies, result.Results...)
|
||||||
|
listURL = result.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableWebshareCountries(proxies []webshareProxy) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var countries []string
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if !proxy.Valid || proxy.CountryCode == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
country := strings.ToLower(proxy.CountryCode)
|
||||||
|
if seen[country] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[country] = true
|
||||||
|
countries = append(countries, country)
|
||||||
|
}
|
||||||
|
if len(countries) == 0 {
|
||||||
|
return []string{"none"}
|
||||||
|
}
|
||||||
|
sort.Strings(countries)
|
||||||
|
return countries
|
||||||
|
}
|
||||||
|
|
||||||
func convertToMarkdown(html string, title string, description string) string {
|
func convertToMarkdown(html string, title string, description string) string {
|
||||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,20 +413,20 @@ func convertToMarkdown(html string, title string, description string) string {
|
|||||||
doc.Find("script, style").Remove()
|
doc.Find("script, style").Remove()
|
||||||
|
|
||||||
var md strings.Builder
|
var md strings.Builder
|
||||||
|
|
||||||
// Add title as H1 if present
|
// Add title as H1 if present
|
||||||
if title != "" {
|
if title != "" {
|
||||||
md.WriteString("# ")
|
md.WriteString("# ")
|
||||||
md.WriteString(title)
|
md.WriteString(title)
|
||||||
md.WriteString("\n\n")
|
md.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add description if present
|
// Add description if present
|
||||||
if description != "" {
|
if description != "" {
|
||||||
md.WriteString(description)
|
md.WriteString(description)
|
||||||
md.WriteString("\n\n")
|
md.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the body
|
// Process the body
|
||||||
doc.Find("body").Each(func(i int, s *goquery.Selection) {
|
doc.Find("body").Each(func(i int, s *goquery.Selection) {
|
||||||
processNode(&md, s)
|
processNode(&md, s)
|
||||||
@@ -226,13 +438,13 @@ func convertToMarkdown(html string, title string, description string) string {
|
|||||||
func processNode(md *strings.Builder, s *goquery.Selection) {
|
func processNode(md *strings.Builder, s *goquery.Selection) {
|
||||||
s.Children().Each(func(i int, child *goquery.Selection) {
|
s.Children().Each(func(i int, child *goquery.Selection) {
|
||||||
nodeType := goquery.NodeName(child)
|
nodeType := goquery.NodeName(child)
|
||||||
|
|
||||||
// Add spacing before block elements (except first child)
|
// Add spacing before block elements (except first child)
|
||||||
isBlock := isBlockElement(nodeType)
|
isBlock := isBlockElement(nodeType)
|
||||||
if isBlock && i > 0 {
|
if isBlock && i > 0 {
|
||||||
md.WriteString("\n")
|
md.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch nodeType {
|
switch nodeType {
|
||||||
case "h1":
|
case "h1":
|
||||||
md.WriteString("\n# ")
|
md.WriteString("\n# ")
|
||||||
@@ -519,8 +731,8 @@ func deduplicate(slice []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func respondWithError(w http.ResponseWriter, status int, err string, details string) {
|
func respondWithError(w http.ResponseWriter, status int, err string, details string) {
|
||||||
w.WriteHeader(status)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(ErrorResponse{
|
json.NewEncoder(w).Encode(ErrorResponse{
|
||||||
Error: err,
|
Error: err,
|
||||||
Details: details,
|
Details: details,
|
||||||
|
|||||||
Reference in New Issue
Block a user