mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
Address gaps found in adversarial verification of the in-flight balance reservation: - Early release: the reservation is now a ref-counted handle carried on the request context. Billing tasks submitted via submit*UsageRecordTask take a ref and return it only after RecordUsage has run; with reservation enabled the balance cache is deducted synchronously inside the billing task, so the reservation is released only after admission can see the deduction. Dropped tasks return their ref immediately. The reservation is renewed every ttl/3 while the handler runs; after the handler returns it is no longer renewed, so the post-handler hold is bounded by one TTL. - Estimate: use the same billing model (channel mapping + BillingModelSource, max over requested/mapped when ambiguous) and ModelPricingResolver (group ModelPricing incl. wildcards, channel per_request/image/video/token overrides), default/group/user rate + peak multiplier, plus image, video, audio and search surcharge kinds. Unpriced requests fail open with a throttled log; new billing.inflight_reservation.fail_closed_on_unpriced (default false) rejects them instead. - Wire images, embeddings, web/x search, alpha search, grok media generation, grok realtime/voice, OpenAI-platform /v1/messages and ResponsesWebSocket (session-level reservation with renewal). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
561 lines
17 KiB
Go
561 lines
17 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/tidwall/gjson"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
defaultGrokWebSearchResults = 5
|
|
maxGrokWebSearchResults = 20
|
|
)
|
|
|
|
func (h *GatewayHandler) WebSearch(c *gin.Context) {
|
|
isXSearch := c.GetBool("grok_x_search_endpoint")
|
|
var req grokStandaloneSearchRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|
"type": "invalid_request_error",
|
|
"message": err.Error(),
|
|
}})
|
|
return
|
|
}
|
|
query := strings.TrimSpace(req.Query)
|
|
if query == "" {
|
|
query = strings.TrimSpace(req.Input)
|
|
}
|
|
if query == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|
"type": "invalid_request_error",
|
|
"message": "query is required",
|
|
}})
|
|
return
|
|
}
|
|
req.Query = query
|
|
maxResults := 0
|
|
if req.MaxResults != nil {
|
|
maxResults = *req.MaxResults
|
|
}
|
|
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
|
|
searchModel := resolveGrokStandaloneSearchModel()
|
|
searchLabel := "web_search"
|
|
if isXSearch {
|
|
searchLabel = "x_search"
|
|
}
|
|
|
|
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
|
if !ok || apiKey == nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{
|
|
"type": "authentication_error",
|
|
"message": "API key required",
|
|
}})
|
|
return
|
|
}
|
|
|
|
if apiKey.Group == nil || apiKey.Group.Platform != "grok" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|
"type": "invalid_request_error",
|
|
"message": searchLabel + " is only supported for grok groups",
|
|
}})
|
|
return
|
|
}
|
|
|
|
// Billing eligibility (same as other requests)
|
|
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
|
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
|
status, code, message, retryAfter := billingErrorDetails(err)
|
|
if retryAfter > 0 {
|
|
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
|
}
|
|
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
|
|
return
|
|
}
|
|
|
|
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
|
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"), Kind: service.InflightEstimatePerRequest, SearchCalls: 1})
|
|
if inflightErr != nil {
|
|
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
|
if retryAfter > 0 {
|
|
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
|
}
|
|
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
|
|
return
|
|
}
|
|
defer inflightDone()
|
|
|
|
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
|
reqLog := requestLogger(c, "handler.gateway.web_search")
|
|
// Audit user search query before upstream Grok web_search traffic.
|
|
auditBody, _ := json.Marshal(map[string]any{
|
|
"messages": []map[string]any{{
|
|
"role": "user", "content": req.Query,
|
|
}},
|
|
})
|
|
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, searchModel, auditBody); decision != nil && !decision.AllowNextStage {
|
|
status := decision.HTTPStatus
|
|
if status == 0 {
|
|
status = http.StatusForbidden
|
|
}
|
|
code := decision.ErrorCode
|
|
if code == "" {
|
|
code = "content_policy_violation"
|
|
}
|
|
msg := decision.ClientMessage
|
|
if msg == "" {
|
|
msg = "Request blocked by content policy"
|
|
}
|
|
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": msg}})
|
|
return
|
|
}
|
|
|
|
// Use exactly the same scheduling as other requests (SelectAccountWithLoadAwareness handles load, rate limit, sticky, etc.)
|
|
groupID := apiKey.GroupID
|
|
if groupID == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|
"type": "invalid_request_error",
|
|
"message": "group required",
|
|
}})
|
|
return
|
|
}
|
|
|
|
failedAccounts := make(map[int64]struct{})
|
|
var account *service.Account
|
|
var accountReleaseFunc func()
|
|
var nativeResp *websearch.SearchResponse
|
|
var providerName string
|
|
var err error
|
|
|
|
// Acquire + release holder for the whole handler (including failover retries).
|
|
defer func() {
|
|
if accountReleaseFunc != nil {
|
|
accountReleaseFunc()
|
|
}
|
|
}()
|
|
|
|
// First attempt + up to 3 failover accounts (max 4 total).
|
|
for attempt := 0; attempt < 4; attempt++ {
|
|
selected, selectErr := h.gatewayService.SelectAccountWithLoadAwareness(
|
|
c.Request.Context(), groupID, "", searchModel, failedAccounts, "", 0,
|
|
)
|
|
if selectErr != nil {
|
|
if attempt == 0 {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
|
|
"type": "scheduling_error",
|
|
"message": selectErr.Error(),
|
|
}})
|
|
return
|
|
}
|
|
break
|
|
}
|
|
if selected == nil || selected.Account == nil {
|
|
if attempt == 0 {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
|
|
"type": "scheduling_error",
|
|
"message": "No available accounts",
|
|
}})
|
|
return
|
|
}
|
|
break
|
|
}
|
|
|
|
release, acquireOK, acquireErr := h.acquireWebSearchAccountSlot(c, selected)
|
|
if !acquireOK {
|
|
// First hop: surface concurrency errors; later hops try another account.
|
|
if attempt == 0 && acquireErr != nil {
|
|
h.handleConcurrencyError(c, acquireErr, "account", false)
|
|
return
|
|
}
|
|
failedAccounts[selected.Account.ID] = struct{}{}
|
|
continue
|
|
}
|
|
account = selected.Account
|
|
accountReleaseFunc = release
|
|
|
|
if isXSearch {
|
|
nativeResp, providerName, err = h.doGrokNativeXSearch(c.Request.Context(), c, account, req, searchModel, maxResults)
|
|
} else {
|
|
nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, maxResults, searchModel)
|
|
}
|
|
if err == nil {
|
|
break
|
|
}
|
|
var failoverErr *service.UpstreamFailoverError
|
|
if !errors.As(err, &failoverErr) || !failoverErr.ShouldRetryNextAccount() {
|
|
break
|
|
}
|
|
failedAccounts[account.ID] = struct{}{}
|
|
if accountReleaseFunc != nil {
|
|
accountReleaseFunc()
|
|
accountReleaseFunc = nil
|
|
}
|
|
account = nil
|
|
}
|
|
if err != nil || nativeResp == nil {
|
|
msg := "web search failed"
|
|
if err != nil {
|
|
msg = err.Error()
|
|
}
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "web_search_error", "message": msg}})
|
|
return
|
|
}
|
|
if account == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
|
|
"type": "scheduling_error",
|
|
"message": "No available accounts",
|
|
}})
|
|
return
|
|
}
|
|
|
|
userAgent := c.GetHeader("User-Agent")
|
|
clientIP := ip.GetClientIP(c)
|
|
inboundEndpoint := GetInboundEndpoint(c)
|
|
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
|
requestPayloadHash := service.HashUsageRequestPayload([]byte(req.Query))
|
|
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
|
// Request IDs are billing idempotency keys, so they must be unique per invocation.
|
|
// Query/IP/UA hashes would collapse repeated identical searches into one charge.
|
|
searchRequestID := searchLabel + ":" + uuid.NewString()
|
|
if apiKey.Group != nil {
|
|
if p := apiKey.Group.GetSearchPricePer1k(); p != nil && *p == 0 {
|
|
logger.L().With(
|
|
zap.String("component", "handler.gateway.web_search"),
|
|
zap.Int64("group_id", apiKey.Group.ID),
|
|
).Info("gateway.web_search.search_price_per_1k_explicit_free")
|
|
}
|
|
}
|
|
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
|
Result: &service.ForwardResult{
|
|
RequestID: searchRequestID,
|
|
Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"),
|
|
SearchCount: 1,
|
|
Duration: 0,
|
|
},
|
|
APIKey: apiKey,
|
|
User: apiKey.User,
|
|
Account: account,
|
|
Subscription: subscription,
|
|
InboundEndpoint: inboundEndpoint,
|
|
UpstreamEndpoint: upstreamEndpoint,
|
|
UserAgent: userAgent,
|
|
IPAddress: clientIP,
|
|
RequestPayloadHash: requestPayloadHash,
|
|
APIKeyService: h.apiKeyService,
|
|
QuotaPlatform: quotaPlatform,
|
|
}); err != nil {
|
|
logger.L().With(
|
|
zap.String("component", "handler.gateway.web_search"),
|
|
zap.Int64("user_id", apiKey.User.ID),
|
|
zap.Int64("api_key_id", apiKey.ID),
|
|
zap.Int64("account_id", account.ID),
|
|
).Error("gateway.web_search.record_usage_failed", zap.Error(err))
|
|
}
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"query": req.Query,
|
|
"results": nativeResp.Results,
|
|
"provider": providerName,
|
|
"max_results": maxResults,
|
|
})
|
|
}
|
|
|
|
// acquireWebSearchAccountSlot resolves an immediate slot or WaitPlan wait.
|
|
// On failure returns (nil, false, err); err is non-nil for concurrency acquire
|
|
// failures so the first hop can map them to HTTP. Wait-queue full returns
|
|
// (nil, false, nil) so failover can try another account.
|
|
func (h *GatewayHandler) acquireWebSearchAccountSlot(
|
|
c *gin.Context,
|
|
selected *service.AccountSelectionResult,
|
|
) (release func(), ok bool, acquireErr error) {
|
|
if selected == nil || selected.Account == nil {
|
|
return nil, false, nil
|
|
}
|
|
if selected.Acquired {
|
|
return selected.ReleaseFunc, true, nil
|
|
}
|
|
if selected.WaitPlan == nil || h.concurrencyHelper == nil {
|
|
return nil, false, nil
|
|
}
|
|
account := selected.Account
|
|
accountWaitCounted := false
|
|
canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selected.WaitPlan.MaxWaiting)
|
|
if waitErr != nil {
|
|
logger.L().Warn("gateway.web_search.account_wait_counter_increment_failed",
|
|
zap.Int64("account_id", account.ID),
|
|
zap.Error(waitErr),
|
|
)
|
|
// Best-effort wait without counter (same as first-hop legacy path).
|
|
} else if !canWait {
|
|
return nil, false, nil
|
|
} else {
|
|
accountWaitCounted = true
|
|
}
|
|
releaseWait := func() {
|
|
if accountWaitCounted {
|
|
h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
|
|
accountWaitCounted = false
|
|
}
|
|
}
|
|
streamStarted := false
|
|
slotRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
|
|
c,
|
|
account.ID,
|
|
selected.WaitPlan.MaxConcurrency,
|
|
selected.WaitPlan.Timeout,
|
|
false,
|
|
&streamStarted,
|
|
)
|
|
releaseWait()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return slotRelease, true, nil
|
|
}
|
|
|
|
// doGrokNativeWebSearch executes web search using the Grok account's native capability
|
|
// by calling the responses endpoint with web_search tool, then normalizes sources to unified format.
|
|
func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int, model string) (*websearch.SearchResponse, string, error) {
|
|
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
|
|
|
|
// Build a minimal responses request that triggers Grok web search tool.
|
|
// Ask for structured metadata because xAI action.sources commonly contains URLs only.
|
|
searchBody := map[string]any{
|
|
"model": xai.ResolveDefaultTextModel(model),
|
|
"input": buildGrokWebSearchPrompt(query, maxResults),
|
|
"tools": []map[string]any{{"type": "web_search"}},
|
|
"include": []string{"web_search_call.action.sources"},
|
|
"store": false,
|
|
"stream": false,
|
|
}
|
|
bodyBytes, _ := json.Marshal(searchBody)
|
|
|
|
respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
// Extract sources from Grok responses output.
|
|
// Prefer web_search_call.action.sources (standardized), fallback to annotations or text links.
|
|
results := extractGrokWebSearchSources(respBytes, maxResults)
|
|
|
|
return &websearch.SearchResponse{
|
|
Results: results,
|
|
Query: query,
|
|
}, "grok-native", nil
|
|
}
|
|
|
|
func (h *GatewayHandler) doGrokNativeXSearch(ctx context.Context, c *gin.Context, account *service.Account, req grokStandaloneSearchRequest, model string, maxResults int) (*websearch.SearchResponse, string, error) {
|
|
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
|
|
bodyBytes, err := buildGrokXSearchResponsesBody(req, model)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
results := extractGrokWebSearchSources(respBytes, maxResults)
|
|
return &websearch.SearchResponse{
|
|
Results: results,
|
|
Query: req.Query,
|
|
}, "grok-native", nil
|
|
}
|
|
|
|
func normalizeGrokWebSearchMaxResults(maxResults int) int {
|
|
if maxResults <= 0 {
|
|
return defaultGrokWebSearchResults
|
|
}
|
|
if maxResults > maxGrokWebSearchResults {
|
|
return maxGrokWebSearchResults
|
|
}
|
|
return maxResults
|
|
}
|
|
|
|
func buildGrokWebSearchPrompt(query string, maxResults int) string {
|
|
return fmt.Sprintf(`Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown.
|
|
|
|
User query:
|
|
%s`, normalizeGrokWebSearchMaxResults(maxResults), query)
|
|
}
|
|
|
|
// extractGrokWebSearchSources returns model-enriched results only when their URLs
|
|
// are present in the actual web_search sources, then falls back to raw sources.
|
|
func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.SearchResult {
|
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
|
return nil
|
|
}
|
|
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
|
|
|
|
sources := make(map[string]websearch.SearchResult)
|
|
var sourceOrder []string
|
|
addSource := func(rawURL, title, snippet string) {
|
|
key, ok := normalizeGrokWebSearchURL(rawURL)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, exists := sources[key]
|
|
if !exists {
|
|
result.URL = strings.TrimSpace(rawURL)
|
|
sourceOrder = append(sourceOrder, key)
|
|
}
|
|
if result.Title == "" {
|
|
result.Title = usableGrokWebSearchTitle(title, result.URL)
|
|
}
|
|
if result.Snippet == "" {
|
|
result.Snippet = strings.TrimSpace(snippet)
|
|
}
|
|
sources[key] = result
|
|
}
|
|
|
|
output := gjson.GetBytes(body, "output")
|
|
output.ForEach(func(_, item gjson.Result) bool {
|
|
callType := item.Get("type").String()
|
|
if callType == "web_search_call" || callType == "x_search_call" {
|
|
sources := item.Get("action.sources")
|
|
if sources.IsArray() {
|
|
sources.ForEach(func(_, src gjson.Result) bool {
|
|
addSource(src.Get("url").String(), src.Get("title").String(), src.Get("snippet").String())
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
if item.Get("type").String() == "message" {
|
|
item.Get("content").ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("type").String() != "output_text" {
|
|
return true
|
|
}
|
|
part.Get("annotations").ForEach(func(_, ann gjson.Result) bool {
|
|
if ann.Get("type").String() == "url_citation" || ann.Get("type").String() == "web" {
|
|
addSource(ann.Get("url").String(), ann.Get("title").String(), "")
|
|
}
|
|
return true
|
|
})
|
|
return true
|
|
})
|
|
}
|
|
return true
|
|
})
|
|
|
|
var out []websearch.SearchResult
|
|
seen := make(map[string]bool)
|
|
output.ForEach(func(_, item gjson.Result) bool {
|
|
if item.Get("type").String() != "message" {
|
|
return true
|
|
}
|
|
item.Get("content").ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("type").String() != "output_text" || len(out) >= maxResults {
|
|
return true
|
|
}
|
|
for _, result := range parseGrokWebSearchStructuredResults(part.Get("text").String()) {
|
|
key, ok := normalizeGrokWebSearchURL(result.URL)
|
|
if !ok || seen[key] {
|
|
continue
|
|
}
|
|
source, allowed := sources[key]
|
|
if !allowed {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
result.URL = source.URL
|
|
result.Title = usableGrokWebSearchTitle(result.Title, result.URL)
|
|
if result.Title == "" {
|
|
result.Title = source.Title
|
|
}
|
|
result.Snippet = strings.TrimSpace(result.Snippet)
|
|
if result.Snippet == "" {
|
|
result.Snippet = source.Snippet
|
|
}
|
|
out = append(out, result)
|
|
if len(out) >= maxResults {
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return len(out) < maxResults
|
|
})
|
|
|
|
for _, key := range sourceOrder {
|
|
if len(out) >= maxResults {
|
|
break
|
|
}
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
result := sources[key]
|
|
if result.Title == "" {
|
|
result.Title = grokWebSearchTitleFromURL(result.URL)
|
|
}
|
|
seen[key] = true
|
|
out = append(out, result)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseGrokWebSearchStructuredResults(text string) []websearch.SearchResult {
|
|
text = strings.TrimSpace(text)
|
|
start := strings.IndexByte(text, '{')
|
|
end := strings.LastIndexByte(text, '}')
|
|
if start < 0 || end < start {
|
|
return nil
|
|
}
|
|
var payload struct {
|
|
Results []websearch.SearchResult `json:"results"`
|
|
}
|
|
if err := json.Unmarshal([]byte(text[start:end+1]), &payload); err != nil {
|
|
return nil
|
|
}
|
|
return payload.Results
|
|
}
|
|
|
|
func normalizeGrokWebSearchURL(rawURL string) (string, bool) {
|
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return "", false
|
|
}
|
|
u.Scheme = strings.ToLower(u.Scheme)
|
|
u.Host = strings.ToLower(u.Host)
|
|
u.Fragment = ""
|
|
if u.Path == "" {
|
|
u.Path = "/"
|
|
}
|
|
return u.String(), true
|
|
}
|
|
|
|
func usableGrokWebSearchTitle(title, rawURL string) string {
|
|
title = strings.TrimSpace(title)
|
|
if title == "" || title == rawURL {
|
|
return ""
|
|
}
|
|
if _, err := strconv.Atoi(title); err == nil {
|
|
return ""
|
|
}
|
|
return title
|
|
}
|
|
|
|
func grokWebSearchTitleFromURL(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || u.Host == "" {
|
|
return rawURL
|
|
}
|
|
return strings.TrimPrefix(strings.ToLower(u.Host), "www.")
|
|
}
|