2766 lines
79 KiB
Go
2766 lines
79 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/textproto"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
goredis "github.com/redis/go-redis/v9"
|
||
"github.com/yuin/goldmark"
|
||
"github.com/yuin/goldmark/extension"
|
||
goldhtml "github.com/yuin/goldmark/renderer/html"
|
||
nethtml "golang.org/x/net/html"
|
||
"golang.org/x/net/html/atom"
|
||
|
||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||
)
|
||
|
||
var (
|
||
errMeijiequanSessionMissing = errors.New("meijiequan session is missing")
|
||
errMeijiequanSessionExpired = errors.New("meijiequan session is expired")
|
||
errMeijiequanAuthRequired = errors.New("meijiequan authentication required")
|
||
errMeijiequanChallengeRequired = errors.New("meijiequan challenge code required")
|
||
errMeijiequanCredentialsMissing = errors.New("meijiequan username or password is missing")
|
||
errMeijiequanOrderRejected = errors.New("meijiequan create order failed")
|
||
)
|
||
|
||
const (
|
||
meijiequanSessionKey = "supplier:meijiequan:session"
|
||
meijiequanSearchOptionsNS = "supplier:meijiequan:media_search"
|
||
meijiequanUpstreamLockKey = "supplier:meijiequan:upstream_lock"
|
||
meijiequanUpstreamThrottleKey = "supplier:meijiequan:upstream_next_at"
|
||
// meijiequanLoginMaxAttempts bounds how many fresh captchas a single login
|
||
// will try before giving up; the OCR resolver may misread occasionally.
|
||
meijiequanLoginMaxAttempts = 3
|
||
)
|
||
|
||
var meijiequanURLPattern = regexp.MustCompile(`(?i)(https?://[^\s"'<>\\))]+|//[^\s"'<>\\))]+)`)
|
||
|
||
type MeijiequanChallenge struct {
|
||
Image []byte
|
||
ContentType string
|
||
FetchedAt time.Time
|
||
}
|
||
|
||
type MeijiequanChallengeCodeResolver interface {
|
||
ResolveCode(ctx context.Context, challenge MeijiequanChallenge) (string, error)
|
||
}
|
||
|
||
type ManualMeijiequanChallengeCodeResolver struct{}
|
||
|
||
func (ManualMeijiequanChallengeCodeResolver) ResolveCode(context.Context, MeijiequanChallenge) (string, error) {
|
||
return "", errMeijiequanChallengeRequired
|
||
}
|
||
|
||
type MeijiequanClient struct {
|
||
mu sync.RWMutex
|
||
redis *goredis.Client
|
||
cfg config.MeijiequanConfig
|
||
resolver MeijiequanChallengeCodeResolver
|
||
}
|
||
|
||
type meijiequanStoredSession struct {
|
||
Cookies []meijiequanStoredCookie `json:"cookies"`
|
||
CSRFToken string `json:"csrf_token,omitempty"`
|
||
UserID string `json:"user_id,omitempty"`
|
||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type meijiequanStoredCookie struct {
|
||
Name string `json:"name"`
|
||
Value string `json:"value"`
|
||
Domain string `json:"domain,omitempty"`
|
||
Path string `json:"path,omitempty"`
|
||
Expires *time.Time `json:"expires,omitempty"`
|
||
HTTPOnly bool `json:"http_only,omitempty"`
|
||
Secure bool `json:"secure,omitempty"`
|
||
}
|
||
|
||
type meijiequanMediaListResponse struct {
|
||
Status int
|
||
Info string
|
||
Data []map[string]any
|
||
Page int
|
||
AllPage int
|
||
AllNum int
|
||
UserID int64
|
||
Raw map[string]json.RawMessage `json:"-"`
|
||
}
|
||
|
||
func (r *meijiequanMediaListResponse) UnmarshalJSON(body []byte) error {
|
||
var raw struct {
|
||
Status any `json:"status"`
|
||
Info string `json:"info"`
|
||
Data []map[string]any `json:"data"`
|
||
Page any `json:"page"`
|
||
AllPage any `json:"all_page"`
|
||
AllNum any `json:"all_num"`
|
||
UserID any `json:"user_id"`
|
||
}
|
||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||
decoder.UseNumber()
|
||
if err := decoder.Decode(&raw); err != nil {
|
||
return err
|
||
}
|
||
r.Status = int(meijiequanFlexibleInt64(raw.Status))
|
||
r.Info = strings.TrimSpace(raw.Info)
|
||
r.Data = raw.Data
|
||
r.Page = int(meijiequanFlexibleInt64(raw.Page))
|
||
r.AllPage = int(meijiequanFlexibleInt64(raw.AllPage))
|
||
r.AllNum = int(meijiequanFlexibleInt64(raw.AllNum))
|
||
r.UserID = meijiequanFlexibleInt64(raw.UserID)
|
||
return nil
|
||
}
|
||
|
||
type MeijiequanMediaPage struct {
|
||
Items []UpsertSupplierMediaResourceInput
|
||
RawItemCount int
|
||
Page int
|
||
AllPage int
|
||
AllNum int
|
||
}
|
||
|
||
type meijiequanSearchOptionsCache struct {
|
||
ModelID int `json:"model_id"`
|
||
Groups []MediaSupplySearchOptionGroup `json:"groups"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type MeijiequanCreateOrderRequest struct {
|
||
UID string
|
||
ModelID int
|
||
Title string
|
||
Content string
|
||
Remark string
|
||
Source string
|
||
Description string
|
||
Promotion string
|
||
Propaganda string
|
||
NewsLink string
|
||
BusinessLicense string
|
||
EnterpriseLogo string
|
||
Coupon string
|
||
ReferenceLink string
|
||
DailyPrice string
|
||
LivePrice string
|
||
Stock string
|
||
Cover string
|
||
OriginalLink string
|
||
BrandID string
|
||
ExtraField1 string
|
||
ExtraField2 string
|
||
OrderBrand string
|
||
ResourceIDArr []string
|
||
ResourceSpareArr []string
|
||
BatchResourceIDs string
|
||
PointTime string
|
||
PointPriceRange string
|
||
LimitTimeEnd string
|
||
Extra map[string]any
|
||
}
|
||
|
||
type MeijiequanCreateOrderResponse struct {
|
||
Status int `json:"status"`
|
||
Info string `json:"info"`
|
||
Data json.RawMessage `json:"data,omitempty"`
|
||
Raw json.RawMessage `json:"-"`
|
||
}
|
||
|
||
type MeijiequanPublishedArticlePage struct {
|
||
Items []MeijiequanPublishedArticle
|
||
Page int
|
||
AllPage int
|
||
AllNum int
|
||
}
|
||
|
||
type MeijiequanPublishedArticle struct {
|
||
OrderID string
|
||
OrderCode string
|
||
Title string
|
||
ResourceID string
|
||
ResourceName string
|
||
PriceCents int64
|
||
ExternalArticleURL string
|
||
Status string
|
||
RejectReason string
|
||
PublishedAt *time.Time
|
||
Raw json.RawMessage
|
||
}
|
||
|
||
type meijiequanLoginForm struct {
|
||
Username string
|
||
Password string
|
||
Code string
|
||
Token string
|
||
SK string
|
||
}
|
||
|
||
type meijiequanLoginResponse struct {
|
||
Status int `json:"status"`
|
||
Info string `json:"info"`
|
||
Data json.RawMessage `json:"data,omitempty"`
|
||
UserID int64 `json:"user_id,omitempty"`
|
||
Raw json.RawMessage `json:"-"`
|
||
}
|
||
|
||
type MeijiequanUploadImageRequest struct {
|
||
Filename string
|
||
ContentType string
|
||
Content []byte
|
||
}
|
||
|
||
type MeijiequanUploadImageResponse struct {
|
||
State string `json:"state"`
|
||
URL string `json:"url"`
|
||
Title string `json:"title"`
|
||
Original string `json:"original"`
|
||
Type string `json:"type"`
|
||
Size int64 `json:"size"`
|
||
Raw json.RawMessage `json:"-"`
|
||
}
|
||
|
||
func NewMeijiequanClient(redis *goredis.Client, cfg config.MeijiequanConfig) *MeijiequanClient {
|
||
client := &MeijiequanClient{
|
||
redis: redis,
|
||
// keep DigitOCRMeijiequanChallengeCodeResolver
|
||
resolver: DigitOCRMeijiequanChallengeCodeResolver{},
|
||
}
|
||
client.SetConfig(cfg)
|
||
return client
|
||
}
|
||
|
||
func (c *MeijiequanClient) WithChallengeCodeResolver(resolver MeijiequanChallengeCodeResolver) *MeijiequanClient {
|
||
if resolver != nil {
|
||
c.mu.Lock()
|
||
c.resolver = resolver
|
||
c.mu.Unlock()
|
||
}
|
||
return c
|
||
}
|
||
|
||
func (c *MeijiequanClient) SetConfig(cfg config.MeijiequanConfig) *MeijiequanClient {
|
||
if c == nil {
|
||
return c
|
||
}
|
||
wrapper := config.MediaSupplyConfig{Meijiequan: cfg}
|
||
config.NormalizeMediaSupplyConfig(&wrapper)
|
||
c.mu.Lock()
|
||
c.cfg = wrapper.Meijiequan
|
||
c.mu.Unlock()
|
||
return c
|
||
}
|
||
|
||
func (c *MeijiequanClient) config() config.MeijiequanConfig {
|
||
if c == nil {
|
||
var cfg config.MeijiequanConfig
|
||
wrapper := config.MediaSupplyConfig{Meijiequan: cfg}
|
||
config.NormalizeMediaSupplyConfig(&wrapper)
|
||
return wrapper.Meijiequan
|
||
}
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
return c.cfg
|
||
}
|
||
|
||
func (c *MeijiequanClient) challengeResolver() MeijiequanChallengeCodeResolver {
|
||
if c == nil {
|
||
return ManualMeijiequanChallengeCodeResolver{}
|
||
}
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
if c.resolver == nil {
|
||
return ManualMeijiequanChallengeCodeResolver{}
|
||
}
|
||
return c.resolver
|
||
}
|
||
|
||
func (c *MeijiequanClient) LoginWithConfiguredAccount(ctx context.Context) (*MeijiequanSessionStatus, error) {
|
||
if c == nil || c.redis == nil {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
cfg := c.config()
|
||
username := strings.TrimSpace(cfg.Username)
|
||
password := strings.TrimSpace(cfg.Password)
|
||
if username == "" || password == "" {
|
||
return nil, errMeijiequanCredentialsMissing
|
||
}
|
||
|
||
var status *MeijiequanSessionStatus
|
||
if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error {
|
||
jar, err := cookiejar.New(nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
client := newMeijiequanHTTPClient(cfg.RequestTimeout, jar)
|
||
token, sk, err := c.fetchLoginForm(lockCtx, client, cfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
loginResp, err := c.resolveAndSubmitLogin(lockCtx, client, cfg, token, sk, username, password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
userID := strings.TrimSpace(cfg.UserID)
|
||
if userID == "" {
|
||
userID = extractMeijiequanUserID(loginResp)
|
||
}
|
||
if userID == "" {
|
||
userID = c.discoverSessionUserID(lockCtx, client, cfg, token)
|
||
}
|
||
var saveErr error
|
||
status, saveErr = c.saveSessionFromJar(lockCtx, jar, token, userID)
|
||
return saveErr
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return status, nil
|
||
}
|
||
|
||
// resolveAndSubmitLogin fetches a captcha, resolves it through the configured
|
||
// resolver, and submits the login form. Because the OCR resolver may misread a
|
||
// captcha, it retries with a fresh captcha on challenge failures up to
|
||
// meijiequanLoginMaxAttempts. The login form token/sk stay valid across captcha
|
||
// refreshes, so only the captcha is re-fetched per attempt.
|
||
//
|
||
// A resolver that signals errMeijiequanChallengeRequired (e.g. the manual
|
||
// resolver) cannot auto-handle the captcha, so the loop returns immediately
|
||
// without burning retries.
|
||
func (c *MeijiequanClient) resolveAndSubmitLogin(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig, token, sk, username, password string) (*meijiequanLoginResponse, error) {
|
||
resolver := c.challengeResolver()
|
||
var lastErr error
|
||
for attempt := 0; attempt < meijiequanLoginMaxAttempts; attempt++ {
|
||
challenge, err := c.fetchChallenge(ctx, client, cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
code, err := resolver.ResolveCode(ctx, challenge)
|
||
if err != nil {
|
||
if errors.Is(err, errMeijiequanChallengeRequired) {
|
||
return nil, err
|
||
}
|
||
lastErr = err
|
||
continue
|
||
}
|
||
code = strings.TrimSpace(code)
|
||
if code == "" {
|
||
lastErr = errMeijiequanChallengeRequired
|
||
continue
|
||
}
|
||
loginResp, err := c.submitLogin(ctx, client, cfg, meijiequanLoginForm{
|
||
Username: username,
|
||
Password: password,
|
||
Code: code,
|
||
Token: token,
|
||
SK: sk,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, errMeijiequanChallengeRequired) {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
return nil, err
|
||
}
|
||
return loginResp, nil
|
||
}
|
||
if lastErr != nil && !errors.Is(lastErr, errMeijiequanChallengeRequired) {
|
||
return nil, fmt.Errorf("%w: %v", errMeijiequanChallengeRequired, lastErr)
|
||
}
|
||
return nil, errMeijiequanChallengeRequired
|
||
}
|
||
|
||
func (c *MeijiequanClient) fetchLoginForm(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig) (string, string, error) {
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+"/supply/index/index", nil)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
c.applyHeaders(httpReq, "")
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return "", "", fmt.Errorf("meijiequan login form http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
fields := parseMeijiequanHiddenInputs(bytes.NewReader(body))
|
||
return strings.TrimSpace(fields["_token"]), strings.TrimSpace(fields["sk"]), nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) fetchChallenge(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig) (MeijiequanChallenge, error) {
|
||
endpoint := cfg.BaseURL + "/api/login/getCode?t=" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return MeijiequanChallenge{}, err
|
||
}
|
||
c.applyHeaders(httpReq, "")
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return MeijiequanChallenge{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||
if err != nil {
|
||
return MeijiequanChallenge{}, err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return MeijiequanChallenge{}, fmt.Errorf("meijiequan challenge http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
return MeijiequanChallenge{
|
||
Image: body,
|
||
ContentType: resp.Header.Get("Content-Type"),
|
||
FetchedAt: time.Now().UTC(),
|
||
}, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) submitLogin(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig, form meijiequanLoginForm) (*meijiequanLoginResponse, error) {
|
||
values := url.Values{}
|
||
values.Set("username", strings.TrimSpace(form.Username))
|
||
values.Set("password", strings.TrimSpace(form.Password))
|
||
values.Set("code", strings.TrimSpace(form.Code))
|
||
if token := strings.TrimSpace(form.Token); token != "" {
|
||
values.Set("_token", token)
|
||
}
|
||
if sk := strings.TrimSpace(form.SK); sk != "" {
|
||
values.Set("sk", sk)
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.BaseURL+"/api/login/loginyz", strings.NewReader(values.Encode()))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
c.applyHeaders(httpReq, form.Token)
|
||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||
httpReq.Header.Set("Referer", cfg.BaseURL+"/supply/index/index")
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Errorf("meijiequan login http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
var parsed meijiequanLoginResponse
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return nil, err
|
||
}
|
||
parsed.Raw = append(parsed.Raw[:0], body...)
|
||
if parsed.Status != 1 {
|
||
if isMeijiequanChallengeMessage(parsed.Info) {
|
||
return nil, errMeijiequanChallengeRequired
|
||
}
|
||
return nil, fmt.Errorf("meijiequan login failed: %s", parsed.Info)
|
||
}
|
||
return &parsed, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) discoverSessionUserID(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig, csrf string) string {
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+"/advSupply/media/getMediaInfo?model_id=1&page=1&page_size=1", nil)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
c.applyHeaders(httpReq, csrf)
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || isMeijiequanAuthResponse(resp) {
|
||
return ""
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
parsed, err := decodeMeijiequanMediaList(body)
|
||
if err != nil || parsed.UserID <= 0 {
|
||
return ""
|
||
}
|
||
return strconv.FormatInt(parsed.UserID, 10)
|
||
}
|
||
|
||
func (c *MeijiequanClient) saveSessionFromJar(ctx context.Context, jar http.CookieJar, csrfToken, userID string) (*MeijiequanSessionStatus, error) {
|
||
if c == nil || c.redis == nil {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
cfg := c.config()
|
||
base, err := url.Parse(cfg.BaseURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := time.Now().UTC()
|
||
expiresAt := now.Add(cfg.SessionTTL)
|
||
session := meijiequanStoredSession{
|
||
Cookies: make([]meijiequanStoredCookie, 0),
|
||
CSRFToken: strings.TrimSpace(csrfToken),
|
||
UserID: strings.TrimSpace(userID),
|
||
ExpiresAt: &expiresAt,
|
||
UpdatedAt: now,
|
||
}
|
||
for _, cookie := range jar.Cookies(base) {
|
||
name := strings.TrimSpace(cookie.Name)
|
||
value := strings.TrimSpace(cookie.Value)
|
||
if name == "" || value == "" {
|
||
continue
|
||
}
|
||
domain := strings.TrimSpace(cookie.Domain)
|
||
if domain == "" {
|
||
domain = base.Hostname()
|
||
}
|
||
path := strings.TrimSpace(cookie.Path)
|
||
if path == "" {
|
||
path = "/"
|
||
}
|
||
var cookieExpires *time.Time
|
||
if !cookie.Expires.IsZero() {
|
||
value := cookie.Expires.UTC()
|
||
cookieExpires = &value
|
||
}
|
||
session.Cookies = append(session.Cookies, meijiequanStoredCookie{
|
||
Name: name,
|
||
Value: value,
|
||
Domain: domain,
|
||
Path: path,
|
||
Expires: cookieExpires,
|
||
HTTPOnly: cookie.HttpOnly,
|
||
Secure: cookie.Secure,
|
||
})
|
||
}
|
||
if len(session.Cookies) == 0 {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
raw, err := json.Marshal(session)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := c.redis.Set(ctx, meijiequanSessionKey, raw, cfg.SessionTTL).Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &MeijiequanSessionStatus{
|
||
Available: true,
|
||
ExpiresAt: &expiresAt,
|
||
UpdatedAt: &now,
|
||
AccountConfigured: true,
|
||
UserID: strings.TrimSpace(userID),
|
||
}, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) ImportSession(ctx context.Context, req ImportMeijiequanSessionRequest) (*MeijiequanSessionStatus, error) {
|
||
if c == nil || c.redis == nil {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
cfg := c.config()
|
||
now := time.Now().UTC()
|
||
expiresAt := req.ExpiresAt
|
||
if expiresAt == nil {
|
||
value := now.Add(cfg.SessionTTL)
|
||
expiresAt = &value
|
||
}
|
||
session := meijiequanStoredSession{
|
||
Cookies: make([]meijiequanStoredCookie, 0, len(req.Cookies)),
|
||
CSRFToken: strings.TrimSpace(req.CSRFToken),
|
||
UserID: strings.TrimSpace(cfg.UserID),
|
||
ExpiresAt: expiresAt,
|
||
UpdatedAt: now,
|
||
}
|
||
for _, cookie := range req.Cookies {
|
||
name := strings.TrimSpace(cookie.Name)
|
||
value := strings.TrimSpace(cookie.Value)
|
||
if name == "" || value == "" {
|
||
continue
|
||
}
|
||
domain := strings.TrimSpace(cookie.Domain)
|
||
if domain == "" {
|
||
domain = "www.meijiequan.com"
|
||
}
|
||
path := strings.TrimSpace(cookie.Path)
|
||
if path == "" {
|
||
path = "/"
|
||
}
|
||
session.Cookies = append(session.Cookies, meijiequanStoredCookie{
|
||
Name: name,
|
||
Value: value,
|
||
Domain: domain,
|
||
Path: path,
|
||
Expires: cookie.Expires,
|
||
HTTPOnly: cookie.HTTPOnly,
|
||
Secure: cookie.Secure,
|
||
})
|
||
}
|
||
if len(session.Cookies) == 0 {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
ttl := time.Until(*expiresAt)
|
||
if ttl <= 0 {
|
||
return nil, errMeijiequanSessionExpired
|
||
}
|
||
raw, err := json.Marshal(session)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := c.redis.Set(ctx, meijiequanSessionKey, raw, ttl).Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return c.sessionStatusFromStoredSession(&session, true), nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) SessionStatus(ctx context.Context) (*MeijiequanSessionStatus, error) {
|
||
session, err := c.loadSession(ctx)
|
||
if err != nil {
|
||
if errors.Is(err, errMeijiequanSessionMissing) || errors.Is(err, errMeijiequanSessionExpired) {
|
||
cfg := c.config()
|
||
return &MeijiequanSessionStatus{
|
||
Available: false,
|
||
AccountConfigured: strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "",
|
||
UserID: strings.TrimSpace(cfg.UserID),
|
||
}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return c.sessionStatusFromStoredSession(session, true), nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) ListMedia(ctx context.Context, modelID, page, pageSize int) (MeijiequanMediaPage, error) {
|
||
var result MeijiequanMediaPage
|
||
if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error {
|
||
cfg := c.config()
|
||
client, csrf, err := c.httpClient(lockCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
endpoint, err := url.Parse(cfg.BaseURL + "/advSupply/media/getMediaInfo")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
q := endpoint.Query()
|
||
q.Set("model_id", strconv.Itoa(modelID))
|
||
q.Set("page", strconv.Itoa(page))
|
||
q.Set("page_size", strconv.Itoa(pageSize))
|
||
endpoint.RawQuery = q.Encode()
|
||
httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodGet, endpoint.String(), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.applyHeaders(httpReq, csrf)
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if isMeijiequanAuthResponse(resp) {
|
||
return errMeijiequanAuthRequired
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("meijiequan list media http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
parsed, err := decodeMeijiequanMediaList(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if parsed.Status == 0 && strings.TrimSpace(parsed.Info) != "" {
|
||
return fmt.Errorf("meijiequan list media failed: %s", parsed.Info)
|
||
}
|
||
result.Page = parsed.Page
|
||
result.AllPage = parsed.AllPage
|
||
result.AllNum = parsed.AllNum
|
||
result.RawItemCount = len(parsed.Data)
|
||
result.Items = make([]UpsertSupplierMediaResourceInput, 0, len(parsed.Data))
|
||
for _, item := range parsed.Data {
|
||
resource, ok := parseMeijiequanMediaItem(modelID, item)
|
||
if ok {
|
||
result.Items = append(result.Items, resource)
|
||
}
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return MeijiequanMediaPage{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) SearchOptions(ctx context.Context, modelID int) (*MediaSupplySearchOptionsResponse, error) {
|
||
if modelID <= 0 {
|
||
modelID = 1
|
||
}
|
||
if cached, ok := c.loadSearchOptionsCache(ctx, modelID); ok {
|
||
return cached, nil
|
||
}
|
||
var result *MediaSupplySearchOptionsResponse
|
||
if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error {
|
||
if cached, ok := c.loadSearchOptionsCache(lockCtx, modelID); ok {
|
||
result = cached
|
||
return nil
|
||
}
|
||
cfg := c.config()
|
||
client, csrf, err := c.httpClient(lockCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
endpoint, err := url.Parse(cfg.BaseURL + "/advSupply/media/getMediaSearch")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
q := endpoint.Query()
|
||
q.Set("model_id", strconv.Itoa(modelID))
|
||
endpoint.RawQuery = q.Encode()
|
||
httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodGet, endpoint.String(), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.applyHeaders(httpReq, csrf)
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if isMeijiequanAuthResponse(resp) {
|
||
return errMeijiequanAuthRequired
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("meijiequan search options http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
groups, err := decodeMeijiequanSearchOptions(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result = &MediaSupplySearchOptionsResponse{
|
||
ModelID: modelID,
|
||
Groups: groups,
|
||
UpdatedAt: time.Now().UTC(),
|
||
}
|
||
c.saveSearchOptionsCache(lockCtx, result)
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) CreateOrder(ctx context.Context, req MeijiequanCreateOrderRequest) (*MeijiequanCreateOrderResponse, error) {
|
||
var result *MeijiequanCreateOrderResponse
|
||
if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error {
|
||
cfg := c.config()
|
||
client, csrf, err := c.httpClient(lockCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(req.UID) == "" {
|
||
return fmt.Errorf("meijiequan user_id is not configured")
|
||
}
|
||
form := meijiequanCreateOrderForm(req)
|
||
requestBody := form.Encode()
|
||
httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodPost, cfg.BaseURL+"/advSupply/article/createOrder", strings.NewReader(requestBody))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.applyHeaders(httpReq, csrf)
|
||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if isMeijiequanAuthResponse(resp) {
|
||
return errMeijiequanAuthRequired
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("meijiequan create order http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
var parsed MeijiequanCreateOrderResponse
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return err
|
||
}
|
||
parsed.Raw = append(parsed.Raw[:0], body...)
|
||
result = &parsed
|
||
if parsed.Status == 0 {
|
||
return fmt.Errorf("%w: %s", errMeijiequanOrderRejected, parsed.Info)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) UploadEditorImage(ctx context.Context, req MeijiequanUploadImageRequest) (*MeijiequanUploadImageResponse, error) {
|
||
content := req.Content
|
||
if len(content) == 0 {
|
||
return nil, fmt.Errorf("meijiequan image content is empty")
|
||
}
|
||
var result *MeijiequanUploadImageResponse
|
||
if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error {
|
||
cfg := c.config()
|
||
client, csrf, err := c.httpClient(lockCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var body bytes.Buffer
|
||
writer := multipart.NewWriter(&body)
|
||
filename := strings.TrimSpace(req.Filename)
|
||
if filename == "" {
|
||
filename = "image.png"
|
||
}
|
||
partHeaders := make(textproto.MIMEHeader)
|
||
partHeaders.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{
|
||
"name": "upfile",
|
||
"filename": filename,
|
||
}))
|
||
if contentType := strings.TrimSpace(req.ContentType); contentType != "" {
|
||
partHeaders.Set("Content-Type", contentType)
|
||
}
|
||
part, err := writer.CreatePart(partHeaders)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := part.Write(content); err != nil {
|
||
return err
|
||
}
|
||
if err := writer.Close(); err != nil {
|
||
return err
|
||
}
|
||
|
||
httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodPost, cfg.BaseURL+"/vendor/ueditor/php/controller.php?action=uploadimage&category=yhdoc", &body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.applyHeaders(httpReq, csrf)
|
||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||
httpReq.Header.Set("Referer", cfg.BaseURL+"/advSupply/article/fillContent?is_clear=1")
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if isMeijiequanAuthResponse(resp) {
|
||
return errMeijiequanAuthRequired
|
||
}
|
||
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("meijiequan upload image http %d: %s", resp.StatusCode, string(responseBody))
|
||
}
|
||
var parsed MeijiequanUploadImageResponse
|
||
if err := json.Unmarshal(responseBody, &parsed); err != nil {
|
||
return fmt.Errorf("meijiequan upload image decode: %w", err)
|
||
}
|
||
parsed.Raw = append(parsed.Raw[:0], responseBody...)
|
||
if !strings.EqualFold(strings.TrimSpace(parsed.State), "SUCCESS") || strings.TrimSpace(parsed.URL) == "" {
|
||
message := strings.TrimSpace(parsed.State)
|
||
if message == "" {
|
||
message = "empty image url"
|
||
}
|
||
return fmt.Errorf("meijiequan upload image failed: %s", message)
|
||
}
|
||
parsed.URL = strings.TrimSpace(parsed.URL)
|
||
result = &parsed
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) ListPublishedArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||
return c.listArticles(ctx, userID, "2", page, pageSize)
|
||
}
|
||
|
||
func (c *MeijiequanClient) ListAllArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||
return c.listArticles(ctx, userID, "", page, pageSize)
|
||
}
|
||
|
||
func (c *MeijiequanClient) ListPendingArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||
return c.listArticles(ctx, userID, "11", page, pageSize)
|
||
}
|
||
|
||
func (c *MeijiequanClient) ListProblemArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||
return c.listArticlePage(ctx, "/advSupply/article/question", userID, "4,5,6", page, pageSize)
|
||
}
|
||
|
||
func (c *MeijiequanClient) listArticles(ctx context.Context, userID, status string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||
return c.listArticlePage(ctx, "/advSupply/article/index", userID, status, page, pageSize)
|
||
}
|
||
|
||
func (c *MeijiequanClient) listArticlePage(ctx context.Context, path, userID, status string, page, pageSize int) (MeijiequanPublishedArticlePage, error) {
|
||
var result MeijiequanPublishedArticlePage
|
||
if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error {
|
||
cfg := c.config()
|
||
client, csrf, err := c.httpClient(lockCtx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
endpoint, err := url.Parse(cfg.BaseURL + path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
q := endpoint.Query()
|
||
if status = strings.TrimSpace(status); status != "" {
|
||
q.Set("status", status)
|
||
}
|
||
if text := strings.TrimSpace(userID); text != "" {
|
||
q.Set("uk", text)
|
||
}
|
||
if page > 0 {
|
||
q.Set("page", strconv.Itoa(page))
|
||
}
|
||
if pageSize > 0 {
|
||
q.Set("page_size", strconv.Itoa(pageSize))
|
||
q.Set("limit", strconv.Itoa(pageSize))
|
||
}
|
||
endpoint.RawQuery = q.Encode()
|
||
httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodGet, endpoint.String(), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
c.applyHeaders(httpReq, csrf)
|
||
httpReq.Header.Set("Accept", "application/json, text/html, */*")
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if isMeijiequanAuthResponse(resp) {
|
||
return errMeijiequanAuthRequired
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("meijiequan published article list http %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
parsed, err := decodeMeijiequanPublishedArticlePage(body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result = parsed
|
||
if result.Page == 0 {
|
||
result.Page = page
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return MeijiequanPublishedArticlePage{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func meijiequanCreateOrderForm(req MeijiequanCreateOrderRequest) url.Values {
|
||
applyMeijiequanCreateOrderExtra(&req, req.Extra)
|
||
prop := meijiequanCreateOrderProp(req)
|
||
if extraProp := meijiequanCreateOrderExtraMap(req.Extra, "prop"); len(extraProp) > 0 {
|
||
for key, value := range extraProp {
|
||
key = strings.TrimSpace(key)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
prop[key] = strings.TrimSpace(meijiequanAnyString(value))
|
||
}
|
||
}
|
||
for key, value := range req.Extra {
|
||
key = strings.TrimSpace(key)
|
||
if key == "" || isKnownMeijiequanCreateOrderExtraKey(key) {
|
||
continue
|
||
}
|
||
if _, exists := prop[key]; !exists {
|
||
prop[key] = strings.TrimSpace(meijiequanAnyString(value))
|
||
}
|
||
}
|
||
resourceIDs, _ := json.Marshal(nonNilMeijiequanStringSlice(req.ResourceIDArr))
|
||
spareIDs, _ := json.Marshal(nonNilMeijiequanStringSlice(req.ResourceSpareArr))
|
||
modelID := req.ModelID
|
||
if modelID <= 0 {
|
||
modelID = 1
|
||
}
|
||
form := url.Values{}
|
||
form.Set("uid", strings.TrimSpace(req.UID))
|
||
for _, key := range meijiequanCreateOrderPropKeys(prop) {
|
||
form.Set("prop["+key+"]", prop[key])
|
||
}
|
||
form.Set("model_id", strconv.Itoa(modelID))
|
||
form.Set("client", "")
|
||
form.Set("voucher_id", "0")
|
||
form.Set("resource_id_arr", string(resourceIDs))
|
||
form.Set("resource_spare_arr", string(spareIDs))
|
||
form.Set("order_brand", strings.TrimSpace(req.OrderBrand))
|
||
form.Set("batch_resource_ids", strings.TrimSpace(req.BatchResourceIDs))
|
||
form.Set("point_time", strings.TrimSpace(req.PointTime))
|
||
form.Set("point_price_range", strings.TrimSpace(req.PointPriceRange))
|
||
form.Set("limit_time_end", strings.TrimSpace(req.LimitTimeEnd))
|
||
return form
|
||
}
|
||
|
||
func meijiequanCreateOrderProp(req MeijiequanCreateOrderRequest) map[string]string {
|
||
return map[string]string{
|
||
"biaoti": strings.TrimSpace(req.Title),
|
||
"laiyuan": strings.TrimSpace(req.Source),
|
||
"miaoshu": strings.TrimSpace(req.Description),
|
||
"neirong": meijiequanArticleHTML(req.Content),
|
||
"tuiguang": strings.TrimSpace(req.Promotion),
|
||
"xuanchuan": strings.TrimSpace(req.Propaganda),
|
||
"xinwenlianjie": strings.TrimSpace(req.NewsLink),
|
||
"yingyezhizhao": strings.TrimSpace(req.BusinessLicense),
|
||
"qiyelogo": strings.TrimSpace(req.EnterpriseLogo),
|
||
"youhuiquan": strings.TrimSpace(req.Coupon),
|
||
"cankaolianjie": strings.TrimSpace(req.ReferenceLink),
|
||
"richangjia": strings.TrimSpace(req.DailyPrice),
|
||
"zhibojia": strings.TrimSpace(req.LivePrice),
|
||
"kucunliang": strings.TrimSpace(req.Stock),
|
||
"fengmian": strings.TrimSpace(req.Cover),
|
||
"yuanwenlianjie": strings.TrimSpace(req.OriginalLink),
|
||
"brand_id": strings.TrimSpace(req.BrandID),
|
||
"extra_field_1": strings.TrimSpace(req.ExtraField1),
|
||
"extra_field_2": strings.TrimSpace(req.ExtraField2),
|
||
}
|
||
}
|
||
|
||
func meijiequanCreateOrderPropKeys(prop map[string]string) []string {
|
||
preferred := []string{
|
||
"biaoti",
|
||
"laiyuan",
|
||
"miaoshu",
|
||
"neirong",
|
||
"tuiguang",
|
||
"xuanchuan",
|
||
"xinwenlianjie",
|
||
"yingyezhizhao",
|
||
"qiyelogo",
|
||
"youhuiquan",
|
||
"cankaolianjie",
|
||
"richangjia",
|
||
"zhibojia",
|
||
"kucunliang",
|
||
"fengmian",
|
||
"yuanwenlianjie",
|
||
"brand_id",
|
||
"extra_field_1",
|
||
"extra_field_2",
|
||
}
|
||
seen := make(map[string]struct{}, len(prop))
|
||
keys := make([]string, 0, len(prop))
|
||
for _, key := range preferred {
|
||
if _, ok := prop[key]; ok {
|
||
keys = append(keys, key)
|
||
seen[key] = struct{}{}
|
||
}
|
||
}
|
||
for key := range prop {
|
||
if _, ok := seen[key]; !ok {
|
||
keys = append(keys, key)
|
||
}
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func applyMeijiequanCreateOrderExtra(req *MeijiequanCreateOrderRequest, extra map[string]any) {
|
||
if req == nil || len(extra) == 0 {
|
||
return
|
||
}
|
||
setStringIfEmpty := func(target *string, keys ...string) {
|
||
if target == nil || strings.TrimSpace(*target) != "" {
|
||
return
|
||
}
|
||
*target = meijiequanCreateOrderExtraString(extra, keys...)
|
||
}
|
||
setStringIfEmpty(&req.Source, "source", "laiyuan")
|
||
setStringIfEmpty(&req.Description, "description", "miaoshu")
|
||
setStringIfEmpty(&req.Promotion, "promotion", "tuiguang")
|
||
setStringIfEmpty(&req.Propaganda, "propaganda", "xuanchuan")
|
||
setStringIfEmpty(&req.NewsLink, "news_link", "xinwenlianjie")
|
||
setStringIfEmpty(&req.BusinessLicense, "business_license", "yingyezhizhao")
|
||
setStringIfEmpty(&req.EnterpriseLogo, "enterprise_logo", "qiyelogo")
|
||
setStringIfEmpty(&req.Coupon, "coupon", "youhuiquan")
|
||
setStringIfEmpty(&req.ReferenceLink, "reference_link", "cankaolianjie")
|
||
setStringIfEmpty(&req.DailyPrice, "daily_price", "richangjia")
|
||
setStringIfEmpty(&req.LivePrice, "live_price", "zhibojia")
|
||
setStringIfEmpty(&req.Stock, "stock", "kucunliang")
|
||
setStringIfEmpty(&req.Cover, "cover", "fengmian")
|
||
setStringIfEmpty(&req.OriginalLink, "original_link", "yuanwenlianjie")
|
||
setStringIfEmpty(&req.BrandID, "brand_id")
|
||
setStringIfEmpty(&req.ExtraField1, "extra_field_1")
|
||
setStringIfEmpty(&req.ExtraField2, "extra_field_2")
|
||
setStringIfEmpty(&req.OrderBrand, "order_brand")
|
||
setStringIfEmpty(&req.BatchResourceIDs, "batch_resource_ids")
|
||
setStringIfEmpty(&req.PointTime, "point_time")
|
||
setStringIfEmpty(&req.PointPriceRange, "point_price_range")
|
||
setStringIfEmpty(&req.LimitTimeEnd, "limit_time_end")
|
||
if len(req.ResourceSpareArr) == 0 {
|
||
req.ResourceSpareArr = meijiequanCreateOrderExtraStringSlice(extra, "resource_spare_arr", "resource_spare_ids")
|
||
}
|
||
}
|
||
|
||
func meijiequanCreateOrderExtraString(extra map[string]any, keys ...string) string {
|
||
for _, key := range keys {
|
||
if value, ok := extra[key]; ok {
|
||
if text := strings.TrimSpace(meijiequanAnyString(value)); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func meijiequanCreateOrderExtraStringSlice(extra map[string]any, keys ...string) []string {
|
||
for _, key := range keys {
|
||
value, ok := extra[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch typed := value.(type) {
|
||
case []string:
|
||
return compactMeijiequanStringSlice(typed)
|
||
case []any:
|
||
values := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
if text := strings.TrimSpace(meijiequanAnyString(item)); text != "" {
|
||
values = append(values, text)
|
||
}
|
||
}
|
||
return values
|
||
case string:
|
||
trimmed := strings.TrimSpace(typed)
|
||
if trimmed == "" {
|
||
return nil
|
||
}
|
||
if strings.HasPrefix(trimmed, "[") {
|
||
var decoded []string
|
||
if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil {
|
||
return compactMeijiequanStringSlice(decoded)
|
||
}
|
||
var decodedAny []any
|
||
if err := json.Unmarshal([]byte(trimmed), &decodedAny); err == nil {
|
||
values := make([]string, 0, len(decodedAny))
|
||
for _, item := range decodedAny {
|
||
if text := strings.TrimSpace(meijiequanAnyString(item)); text != "" {
|
||
values = append(values, text)
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
}
|
||
parts := strings.Split(trimmed, ",")
|
||
return compactMeijiequanStringSlice(parts)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func compactMeijiequanStringSlice(values []string) []string {
|
||
compacted := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
if text := strings.TrimSpace(value); text != "" {
|
||
compacted = append(compacted, text)
|
||
}
|
||
}
|
||
return compacted
|
||
}
|
||
|
||
func meijiequanCreateOrderExtraMap(extra map[string]any, key string) map[string]any {
|
||
if len(extra) == 0 {
|
||
return nil
|
||
}
|
||
value, ok := extra[key]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
switch typed := value.(type) {
|
||
case map[string]any:
|
||
return typed
|
||
case map[string]string:
|
||
result := make(map[string]any, len(typed))
|
||
for key, value := range typed {
|
||
result[key] = value
|
||
}
|
||
return result
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func isKnownMeijiequanCreateOrderExtraKey(key string) bool {
|
||
switch strings.TrimSpace(key) {
|
||
case "prop",
|
||
"source", "laiyuan",
|
||
"description", "miaoshu",
|
||
"promotion", "tuiguang",
|
||
"propaganda", "xuanchuan",
|
||
"news_link", "xinwenlianjie",
|
||
"business_license", "yingyezhizhao",
|
||
"enterprise_logo", "qiyelogo",
|
||
"coupon", "youhuiquan",
|
||
"reference_link", "cankaolianjie",
|
||
"daily_price", "richangjia",
|
||
"live_price", "zhibojia",
|
||
"stock", "kucunliang",
|
||
"cover", "fengmian",
|
||
"original_link", "yuanwenlianjie",
|
||
"brand_id",
|
||
"extra_field_1",
|
||
"extra_field_2",
|
||
"order_brand",
|
||
"batch_resource_ids",
|
||
"point_time",
|
||
"point_price_range",
|
||
"limit_time_end",
|
||
"resource_spare_arr",
|
||
"resource_spare_ids":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func nonNilMeijiequanStringSlice(values []string) []string {
|
||
if values == nil {
|
||
return []string{}
|
||
}
|
||
return values
|
||
}
|
||
|
||
func meijiequanArticleHTML(content string) string {
|
||
source := strings.TrimSpace(content)
|
||
if source == "" {
|
||
return ""
|
||
}
|
||
return markdownToMeijiequanHTML(source)
|
||
}
|
||
|
||
func markdownToMeijiequanHTML(markdown string) string {
|
||
var output bytes.Buffer
|
||
renderer := goldmark.New(
|
||
goldmark.WithExtensions(extension.GFM),
|
||
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
|
||
)
|
||
if err := renderer.Convert([]byte(markdown), &output); err != nil {
|
||
return ""
|
||
}
|
||
return addMeijiequanImageSrcAttributes(strings.TrimSpace(output.String()))
|
||
}
|
||
|
||
func addMeijiequanImageSrcAttributes(fragment string) string {
|
||
if !strings.Contains(strings.ToLower(fragment), "<img") {
|
||
return fragment
|
||
}
|
||
contextNode := &nethtml.Node{
|
||
Type: nethtml.ElementNode,
|
||
Data: "body",
|
||
DataAtom: atom.Body,
|
||
}
|
||
nodes, err := nethtml.ParseFragment(strings.NewReader(fragment), contextNode)
|
||
if err != nil {
|
||
return fragment
|
||
}
|
||
for _, node := range nodes {
|
||
addMeijiequanImageSrcAttribute(node)
|
||
}
|
||
var builder strings.Builder
|
||
for _, node := range nodes {
|
||
if err := nethtml.Render(&builder, node); err != nil {
|
||
return fragment
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func addMeijiequanImageSrcAttribute(node *nethtml.Node) {
|
||
if node == nil {
|
||
return
|
||
}
|
||
if node.Type == nethtml.ElementNode && strings.EqualFold(node.Data, "img") {
|
||
src := ""
|
||
originalSrc := ""
|
||
dataSrc := ""
|
||
attrs := make([]nethtml.Attribute, 0, len(node.Attr)+1)
|
||
for _, attr := range node.Attr {
|
||
switch strings.ToLower(strings.TrimSpace(attr.Key)) {
|
||
case "src":
|
||
src = strings.TrimSpace(attr.Val)
|
||
case "_src":
|
||
originalSrc = strings.TrimSpace(attr.Val)
|
||
case "data-src":
|
||
dataSrc = strings.TrimSpace(attr.Val)
|
||
case "data-asset-id":
|
||
continue
|
||
}
|
||
attrs = append(attrs, attr)
|
||
}
|
||
if src == "" {
|
||
src = firstNonEmptyText(originalSrc, dataSrc)
|
||
}
|
||
if src != "" {
|
||
for idx := range attrs {
|
||
switch strings.ToLower(strings.TrimSpace(attrs[idx].Key)) {
|
||
case "src":
|
||
attrs[idx].Val = src
|
||
case "_src":
|
||
attrs[idx].Val = src
|
||
}
|
||
}
|
||
if !meijiequanHTMLAttrsHaveKey(attrs, "src") {
|
||
attrs = append(attrs, nethtml.Attribute{Key: "src", Val: src})
|
||
}
|
||
if !meijiequanHTMLAttrsHaveKey(attrs, "_src") {
|
||
attrs = append(attrs, nethtml.Attribute{Key: "_src", Val: src})
|
||
}
|
||
}
|
||
node.Attr = attrs
|
||
}
|
||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||
addMeijiequanImageSrcAttribute(child)
|
||
}
|
||
}
|
||
|
||
func meijiequanHTMLAttrsHaveKey(attrs []nethtml.Attribute, key string) bool {
|
||
for _, attr := range attrs {
|
||
if strings.EqualFold(strings.TrimSpace(attr.Key), key) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (c *MeijiequanClient) httpClient(ctx context.Context) (*http.Client, string, error) {
|
||
session, err := c.loadSession(ctx)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
cfg := c.config()
|
||
jar, err := cookiejar.New(nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
base, err := url.Parse(cfg.BaseURL)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
cookies := make([]*http.Cookie, 0, len(session.Cookies))
|
||
for _, item := range session.Cookies {
|
||
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.Value) == "" {
|
||
continue
|
||
}
|
||
cookie := &http.Cookie{
|
||
Name: item.Name,
|
||
Value: item.Value,
|
||
Domain: item.Domain,
|
||
Path: item.Path,
|
||
HttpOnly: item.HTTPOnly,
|
||
Secure: item.Secure,
|
||
}
|
||
if item.Expires != nil {
|
||
cookie.Expires = *item.Expires
|
||
}
|
||
cookies = append(cookies, cookie)
|
||
}
|
||
jar.SetCookies(base, cookies)
|
||
return newMeijiequanHTTPClient(cfg.RequestTimeout, jar), session.CSRFToken, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) loadSession(ctx context.Context) (*meijiequanStoredSession, error) {
|
||
if c == nil || c.redis == nil {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
raw, err := c.redis.Get(ctx, meijiequanSessionKey).Bytes()
|
||
if err != nil {
|
||
if errors.Is(err, goredis.Nil) {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
return nil, err
|
||
}
|
||
var session meijiequanStoredSession
|
||
if err := json.Unmarshal(raw, &session); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(session.Cookies) == 0 {
|
||
return nil, errMeijiequanSessionMissing
|
||
}
|
||
if session.ExpiresAt != nil && time.Now().After(*session.ExpiresAt) {
|
||
_ = c.redis.Del(ctx, meijiequanSessionKey).Err()
|
||
return nil, errMeijiequanSessionExpired
|
||
}
|
||
return &session, nil
|
||
}
|
||
|
||
func (c *MeijiequanClient) loadSearchOptionsCache(ctx context.Context, modelID int) (*MediaSupplySearchOptionsResponse, bool) {
|
||
if c == nil || c.redis == nil {
|
||
return nil, false
|
||
}
|
||
raw, err := c.redis.Get(ctx, meijiequanSearchOptionsKey(modelID)).Bytes()
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
var cached meijiequanSearchOptionsCache
|
||
if err := json.Unmarshal(raw, &cached); err != nil {
|
||
return nil, false
|
||
}
|
||
if len(cached.Groups) == 0 {
|
||
return nil, false
|
||
}
|
||
return &MediaSupplySearchOptionsResponse{
|
||
ModelID: cached.ModelID,
|
||
Groups: cached.Groups,
|
||
UpdatedAt: cached.UpdatedAt,
|
||
}, true
|
||
}
|
||
|
||
func (c *MeijiequanClient) saveSearchOptionsCache(ctx context.Context, data *MediaSupplySearchOptionsResponse) {
|
||
if c == nil || c.redis == nil || data == nil || data.ModelID <= 0 || len(data.Groups) == 0 {
|
||
return
|
||
}
|
||
raw, err := json.Marshal(meijiequanSearchOptionsCache{
|
||
ModelID: data.ModelID,
|
||
Groups: data.Groups,
|
||
UpdatedAt: data.UpdatedAt,
|
||
})
|
||
if err != nil {
|
||
return
|
||
}
|
||
cfg := c.config()
|
||
ttl := cfg.SearchOptionsTTL
|
||
if ttl <= 0 {
|
||
ttl = 12 * time.Hour
|
||
}
|
||
_ = c.redis.Set(ctx, meijiequanSearchOptionsKey(data.ModelID), raw, ttl).Err()
|
||
}
|
||
|
||
func meijiequanSearchOptionsKey(modelID int) string {
|
||
if modelID <= 0 {
|
||
modelID = 1
|
||
}
|
||
return fmt.Sprintf("%s:%d", meijiequanSearchOptionsNS, modelID)
|
||
}
|
||
|
||
func (c *MeijiequanClient) withUpstreamLock(ctx context.Context, fn func(context.Context) error) error {
|
||
if c == nil || c.redis == nil {
|
||
return fn(ctx)
|
||
}
|
||
cfg := c.config()
|
||
token := strconv.FormatInt(time.Now().UnixNano(), 36)
|
||
deadline := time.Now().Add(cfg.UpstreamLockTTL)
|
||
for {
|
||
ok, err := c.redis.SetNX(ctx, meijiequanUpstreamLockKey, token, cfg.UpstreamLockTTL).Result()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ok {
|
||
defer func() {
|
||
unlockScript := goredis.NewScript(`if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end`)
|
||
_ = unlockScript.Run(context.Background(), c.redis, []string{meijiequanUpstreamLockKey}, token).Err()
|
||
}()
|
||
if err := c.waitForUpstreamTurn(ctx, cfg.UpstreamMinInterval); err != nil {
|
||
return err
|
||
}
|
||
return fn(ctx)
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return fmt.Errorf("meijiequan upstream lock timeout")
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(250 * time.Millisecond):
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *MeijiequanClient) waitForUpstreamTurn(ctx context.Context, minInterval time.Duration) error {
|
||
if c == nil || c.redis == nil || minInterval <= 0 {
|
||
return nil
|
||
}
|
||
now := time.Now().UnixMilli()
|
||
intervalMillis := minInterval.Milliseconds()
|
||
if intervalMillis <= 0 {
|
||
intervalMillis = 1
|
||
}
|
||
throttleScript := goredis.NewScript(`
|
||
local now = tonumber(ARGV[1])
|
||
local interval = tonumber(ARGV[2])
|
||
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
|
||
local scheduled = now
|
||
if current > now then
|
||
scheduled = current
|
||
end
|
||
redis.call("SET", KEYS[1], scheduled + interval, "PX", interval * 4)
|
||
return scheduled - now
|
||
`)
|
||
waitMillis, err := throttleScript.Run(ctx, c.redis, []string{meijiequanUpstreamThrottleKey}, now, intervalMillis).Int64()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if waitMillis <= 0 {
|
||
return nil
|
||
}
|
||
timer := time.NewTimer(time.Duration(waitMillis) * time.Millisecond)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-timer.C:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func (c *MeijiequanClient) applyHeaders(req *http.Request, csrf string) {
|
||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; GeoRanklyMediaSupply/1.0)")
|
||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||
if csrf != "" {
|
||
req.Header.Set("X-CSRF-TOKEN", csrf)
|
||
}
|
||
}
|
||
|
||
func newMeijiequanHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client {
|
||
return &http.Client{
|
||
Jar: jar,
|
||
Timeout: timeout,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 3 {
|
||
return http.ErrUseLastResponse
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func (c *MeijiequanClient) sessionStatusFromStoredSession(session *meijiequanStoredSession, available bool) *MeijiequanSessionStatus {
|
||
cfg := c.config()
|
||
userID := strings.TrimSpace(cfg.UserID)
|
||
if session != nil && strings.TrimSpace(session.UserID) != "" {
|
||
userID = strings.TrimSpace(session.UserID)
|
||
}
|
||
var expiresAt *time.Time
|
||
var updatedAt *time.Time
|
||
if session != nil {
|
||
expiresAt = session.ExpiresAt
|
||
updatedAt = &session.UpdatedAt
|
||
}
|
||
return &MeijiequanSessionStatus{
|
||
Available: available,
|
||
ExpiresAt: expiresAt,
|
||
UpdatedAt: updatedAt,
|
||
AccountConfigured: strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "",
|
||
UserID: userID,
|
||
}
|
||
}
|
||
|
||
func isMeijiequanAuthResponse(resp *http.Response) bool {
|
||
if resp == nil {
|
||
return false
|
||
}
|
||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||
return true
|
||
}
|
||
location := resp.Header.Get("Location")
|
||
return strings.Contains(location, "/login") || strings.Contains(location, "/supply/index/index")
|
||
}
|
||
|
||
func decodeMeijiequanMediaList(body []byte) (*meijiequanMediaListResponse, error) {
|
||
var parsed meijiequanMediaListResponse
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return nil, err
|
||
}
|
||
return &parsed, nil
|
||
}
|
||
|
||
func decodeMeijiequanSearchOptions(body []byte) ([]MediaSupplySearchOptionGroup, error) {
|
||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||
decoder.UseNumber()
|
||
var root any
|
||
if err := decoder.Decode(&root); err != nil {
|
||
return nil, err
|
||
}
|
||
if nested, ok := firstSearchOptionsObject(root); ok {
|
||
root = nested
|
||
}
|
||
record, ok := root.(map[string]any)
|
||
if !ok {
|
||
return nil, fmt.Errorf("meijiequan search options response is not an object")
|
||
}
|
||
order := []string{
|
||
"pindaoleixing",
|
||
"zonghemenhu",
|
||
"quyu",
|
||
"shouluxiaoguo",
|
||
"tebiehangye",
|
||
"lianjieleixing",
|
||
"chugaosudu",
|
||
"aiInclude",
|
||
"otherOption",
|
||
}
|
||
used := make(map[string]struct{}, len(record))
|
||
groups := make([]MediaSupplySearchOptionGroup, 0, len(record))
|
||
for _, key := range order {
|
||
if group, ok := parseMeijiequanSearchOptionGroup(key, record[key]); ok {
|
||
groups = append(groups, group)
|
||
used[key] = struct{}{}
|
||
}
|
||
}
|
||
for key, value := range record {
|
||
if _, exists := used[key]; exists {
|
||
continue
|
||
}
|
||
if group, ok := parseMeijiequanSearchOptionGroup(key, value); ok {
|
||
groups = append(groups, group)
|
||
}
|
||
}
|
||
return groups, nil
|
||
}
|
||
|
||
func decodeMeijiequanPublishedArticlePage(body []byte) (MeijiequanPublishedArticlePage, error) {
|
||
trimmed := bytes.TrimSpace(body)
|
||
if len(trimmed) == 0 {
|
||
return MeijiequanPublishedArticlePage{}, nil
|
||
}
|
||
if trimmed[0] == '{' || trimmed[0] == '[' {
|
||
if page, ok, err := decodeMeijiequanPublishedArticleJSON(trimmed); ok || err != nil {
|
||
return page, err
|
||
}
|
||
}
|
||
return parseMeijiequanPublishedArticleHTML(bytes.NewReader(trimmed))
|
||
}
|
||
|
||
func decodeMeijiequanPublishedArticleJSON(body []byte) (MeijiequanPublishedArticlePage, bool, error) {
|
||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||
decoder.UseNumber()
|
||
var root any
|
||
if err := decoder.Decode(&root); err != nil {
|
||
return MeijiequanPublishedArticlePage{}, true, err
|
||
}
|
||
page := MeijiequanPublishedArticlePage{}
|
||
if record, ok := root.(map[string]any); ok {
|
||
if status := int(meijiequanFlexibleInt64(record["status"])); status == 0 {
|
||
info := strings.TrimSpace(meijiequanAnyString(record["info"]))
|
||
if info != "" {
|
||
return page, true, fmt.Errorf("meijiequan published article list failed: %s", info)
|
||
}
|
||
}
|
||
page.Page = int(meijiequanFlexibleInt64(firstMapValue(record, "page", "current_page")))
|
||
page.AllPage = int(meijiequanFlexibleInt64(firstMapValue(record, "all_page", "last_page", "pages")))
|
||
page.AllNum = int(meijiequanFlexibleInt64(firstMapValue(record, "all_num", "total", "count")))
|
||
}
|
||
items := meijiequanPublishedArticleJSONItems(root)
|
||
if items == nil {
|
||
return page, false, nil
|
||
}
|
||
page.Items = make([]MeijiequanPublishedArticle, 0, len(items))
|
||
for _, item := range items {
|
||
if article, ok := parseMeijiequanPublishedArticleMap(item); ok {
|
||
page.Items = append(page.Items, article)
|
||
}
|
||
}
|
||
return page, true, nil
|
||
}
|
||
|
||
func meijiequanPublishedArticleJSONItems(value any) []map[string]any {
|
||
switch typed := value.(type) {
|
||
case []any:
|
||
items := make([]map[string]any, 0, len(typed))
|
||
for _, item := range typed {
|
||
if record, ok := item.(map[string]any); ok {
|
||
items = append(items, record)
|
||
}
|
||
}
|
||
return items
|
||
case map[string]any:
|
||
for _, key := range []string{"data", "list", "rows", "items", "result"} {
|
||
nested, ok := typed[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if items := meijiequanPublishedArticleJSONItems(nested); items != nil {
|
||
return items
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseMeijiequanPublishedArticleMap(item map[string]any) (MeijiequanPublishedArticle, bool) {
|
||
title := strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "biaoti", "title", "article_title", "name")))
|
||
articleURL := normalizeMeijiequanArticleURL(meijiequanAnyString(firstMapValue(item,
|
||
"xinwenlianjie",
|
||
"article_url",
|
||
"external_article_url",
|
||
"url",
|
||
"link",
|
||
"publish_url",
|
||
"case_url",
|
||
)))
|
||
if articleURL != "" && !isLikelyPublishedArticleURL(articleURL) {
|
||
articleURL = ""
|
||
}
|
||
if title == "" && articleURL == "" {
|
||
return MeijiequanPublishedArticle{}, false
|
||
}
|
||
orderCode := strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "order_code", "code", "sn", "order_sn", "dingdanhao", "order_no", "orderno", "danhao")))
|
||
if !isLikelyMeijiequanOrderCode(orderCode) {
|
||
orderCode = firstMeijiequanOrderCode(orderCode)
|
||
}
|
||
raw, _ := json.Marshal(item)
|
||
return MeijiequanPublishedArticle{
|
||
OrderID: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "id", "order_id", "article_id"))),
|
||
OrderCode: orderCode,
|
||
Title: title,
|
||
ResourceID: normalizeMeijiequanPublishedResourceID(meijiequanAnyString(firstMapValue(item, "resource_id", "media_id", "uid", "mid"))),
|
||
ResourceName: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "meiti", "media_name", "resource_name", "name"))),
|
||
PriceCents: firstMeijiequanArticlePriceCents(item),
|
||
ExternalArticleURL: articleURL,
|
||
Status: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "status", "status_text", "state"))),
|
||
RejectReason: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "reject_reason", "reason", "tuigao_reason", "tuigaoyuanyin", "refund_reason", "error_message"))),
|
||
PublishedAt: parseMeijiequanTime(meijiequanAnyString(firstMapValue(item,
|
||
"published_at",
|
||
"publish_time",
|
||
"fabushijian",
|
||
"updated_at",
|
||
"created_at",
|
||
))),
|
||
Raw: raw,
|
||
}, true
|
||
}
|
||
|
||
func parseMeijiequanPublishedArticleHTML(r io.Reader) (MeijiequanPublishedArticlePage, error) {
|
||
root, err := nethtml.Parse(r)
|
||
if err != nil {
|
||
return MeijiequanPublishedArticlePage{}, err
|
||
}
|
||
rows := meijiequanTableRows(root)
|
||
page := MeijiequanPublishedArticlePage{Items: make([]MeijiequanPublishedArticle, 0, len(rows))}
|
||
for _, row := range rows {
|
||
cells := meijiequanElementChildren(row, "td")
|
||
if len(cells) == 0 {
|
||
continue
|
||
}
|
||
article, ok := parseMeijiequanPublishedArticleHTMLRow(row, cells)
|
||
if ok {
|
||
page.Items = append(page.Items, article)
|
||
}
|
||
}
|
||
if len(page.Items) == 0 {
|
||
page.Items = parseMeijiequanPublishedArticleHTMLLinks(root)
|
||
}
|
||
return page, nil
|
||
}
|
||
|
||
func meijiequanTableRows(node *nethtml.Node) []*nethtml.Node {
|
||
if node == nil {
|
||
return nil
|
||
}
|
||
rows := make([]*nethtml.Node, 0)
|
||
var walk func(*nethtml.Node)
|
||
walk = func(current *nethtml.Node) {
|
||
if current == nil {
|
||
return
|
||
}
|
||
if current.Type == nethtml.ElementNode && strings.EqualFold(current.Data, "tr") {
|
||
rows = append(rows, current)
|
||
}
|
||
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
||
walk(child)
|
||
}
|
||
}
|
||
walk(node)
|
||
return rows
|
||
}
|
||
|
||
func meijiequanElementChildren(node *nethtml.Node, tag string) []*nethtml.Node {
|
||
children := make([]*nethtml.Node, 0)
|
||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||
if child.Type == nethtml.ElementNode && strings.EqualFold(child.Data, tag) {
|
||
children = append(children, child)
|
||
}
|
||
}
|
||
return children
|
||
}
|
||
|
||
func parseMeijiequanPublishedArticleHTMLRow(row *nethtml.Node, cells []*nethtml.Node) (MeijiequanPublishedArticle, bool) {
|
||
cellTexts := make([]string, 0, len(cells))
|
||
texts := make([]string, 0, len(cells))
|
||
for _, cell := range cells {
|
||
text := strings.TrimSpace(collapseMeijiequanCJKSpaces(normalizeWhitespace(meijiequanNodeText(cell))))
|
||
cellTexts = append(cellTexts, text)
|
||
if text != "" {
|
||
texts = append(texts, text)
|
||
}
|
||
}
|
||
rowText := strings.Join(texts, " ")
|
||
if !looksLikeMeijiequanPublishedRow(rowText) {
|
||
return MeijiequanPublishedArticle{}, false
|
||
}
|
||
tableHeader := meijiequanTableHeader(row)
|
||
columnValues := meijiequanArticleColumnValues(tableHeader, cellTexts)
|
||
links := meijiequanLinks(row)
|
||
articleURL := extractMeijiequanRealBacklink(row)
|
||
orderCode := strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-order-code", "data-order-no", "data-sn", "data-code"))
|
||
if orderCode == "" {
|
||
orderCode = extractMeijiequanOrderCodeFromTexts(preferredMeijiequanTexts(columnValues, "订单号", texts))
|
||
}
|
||
resourceName := strings.TrimSpace(columnValues["资源名称"])
|
||
if resourceName == "" {
|
||
resourceName = extractMeijiequanResourceNameFromTexts(texts)
|
||
}
|
||
title := strings.TrimSpace(columnValues["标题"])
|
||
if title == "" {
|
||
title = extractMeijiequanArticleTitleFromTexts(texts, resourceName)
|
||
}
|
||
for _, link := range links {
|
||
if articleURL == "" && isLikelyPublishedArticleURL(link.Href) {
|
||
articleURL = normalizeMeijiequanArticleURL(link.Href)
|
||
if title == "" && !isMeijiequanGenericPublishedLinkText(link.Text) {
|
||
title = strings.TrimSpace(link.Text)
|
||
}
|
||
}
|
||
if title == "" && link.Text != "" && !isMeijiequanGenericPublishedLinkText(link.Text) {
|
||
title = strings.TrimSpace(link.Text)
|
||
}
|
||
}
|
||
if title == "" && len(texts) > 0 {
|
||
for _, text := range texts {
|
||
if !isMeijiequanGenericPublishedLinkText(text) {
|
||
title = text
|
||
break
|
||
}
|
||
}
|
||
}
|
||
resourceID := normalizeMeijiequanPublishedResourceID(firstMeijiequanHTMLAttr(row, "data-resource-id", "data-media-id", "data-mid", "data-uid"))
|
||
if resourceID == "" {
|
||
resourceID = extractMeijiequanResourceIDFromText(rowText)
|
||
}
|
||
status := strings.TrimSpace(columnValues["状态"])
|
||
if status == "" {
|
||
status = extractMeijiequanPublishedStatus(rowText)
|
||
}
|
||
priceCents, _ := parsePriceCents(columnValues["价格"])
|
||
rejectReason := strings.TrimSpace(columnValues["退稿原因"])
|
||
return MeijiequanPublishedArticle{
|
||
OrderID: strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-id", "data-order-id", "data-article-id")),
|
||
OrderCode: orderCode,
|
||
Title: strings.TrimSpace(title),
|
||
ResourceID: resourceID,
|
||
ResourceName: resourceName,
|
||
PriceCents: priceCents,
|
||
ExternalArticleURL: articleURL,
|
||
Status: status,
|
||
RejectReason: rejectReason,
|
||
}, articleURL != "" || title != "" || orderCode != ""
|
||
}
|
||
|
||
func meijiequanTableHeader(row *nethtml.Node) []string {
|
||
if row == nil || row.Parent == nil {
|
||
return nil
|
||
}
|
||
table := row.Parent
|
||
for table != nil && !(table.Type == nethtml.ElementNode && strings.EqualFold(table.Data, "table")) {
|
||
table = table.Parent
|
||
}
|
||
if table == nil {
|
||
return nil
|
||
}
|
||
var header []string
|
||
var walk func(*nethtml.Node)
|
||
walk = func(current *nethtml.Node) {
|
||
if current == nil || len(header) > 0 {
|
||
return
|
||
}
|
||
if current.Type == nethtml.ElementNode && strings.EqualFold(current.Data, "tr") {
|
||
cells := meijiequanElementChildren(current, "th")
|
||
if len(cells) == 0 {
|
||
cells = meijiequanElementChildren(current, "td")
|
||
}
|
||
values := make([]string, 0, len(cells))
|
||
for _, cell := range cells {
|
||
values = append(values, strings.TrimSpace(collapseMeijiequanCJKSpaces(normalizeWhitespace(meijiequanNodeText(cell)))))
|
||
}
|
||
if meijiequanLooksLikeArticleHeader(values) {
|
||
header = values
|
||
return
|
||
}
|
||
}
|
||
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
||
walk(child)
|
||
}
|
||
}
|
||
walk(table)
|
||
return header
|
||
}
|
||
|
||
func meijiequanLooksLikeArticleHeader(values []string) bool {
|
||
joined := strings.Join(values, " ")
|
||
return strings.Contains(joined, "订单号") &&
|
||
strings.Contains(joined, "标题") &&
|
||
strings.Contains(joined, "资源名称") &&
|
||
strings.Contains(joined, "状态")
|
||
}
|
||
|
||
func meijiequanArticleColumnValues(header, values []string) map[string]string {
|
||
out := make(map[string]string)
|
||
for idx, name := range header {
|
||
if idx >= len(values) {
|
||
continue
|
||
}
|
||
key := normalizeMeijiequanHeaderName(name)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
out[key] = strings.TrimSpace(values[idx])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeMeijiequanHeaderName(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
switch {
|
||
case strings.Contains(value, "订单号"):
|
||
return "订单号"
|
||
case strings.Contains(value, "资源名称"):
|
||
return "资源名称"
|
||
case strings.Contains(value, "价格") || strings.Contains(value, "金额"):
|
||
return "价格"
|
||
case strings.Contains(value, "退稿原因"):
|
||
return "退稿原因"
|
||
case strings.Contains(value, "状态"):
|
||
return "状态"
|
||
case strings.Contains(value, "标题"):
|
||
return "标题"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func preferredMeijiequanTexts(values map[string]string, key string, fallback []string) []string {
|
||
value := strings.TrimSpace(values[key])
|
||
if value == "" {
|
||
return fallback
|
||
}
|
||
return []string{value}
|
||
}
|
||
|
||
type meijiequanHTMLLink struct {
|
||
Href string
|
||
Text string
|
||
}
|
||
|
||
func meijiequanLinks(node *nethtml.Node) []meijiequanHTMLLink {
|
||
links := make([]meijiequanHTMLLink, 0)
|
||
var walk func(*nethtml.Node)
|
||
walk = func(current *nethtml.Node) {
|
||
if current == nil {
|
||
return
|
||
}
|
||
if current.Type == nethtml.ElementNode && strings.EqualFold(current.Data, "a") {
|
||
href := strings.TrimSpace(meijiequanAttr(current, "href"))
|
||
if href != "" && !strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
||
links = append(links, meijiequanHTMLLink{
|
||
Href: href,
|
||
Text: strings.TrimSpace(normalizeWhitespace(meijiequanNodeText(current))),
|
||
})
|
||
}
|
||
}
|
||
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
||
walk(child)
|
||
}
|
||
}
|
||
walk(node)
|
||
return links
|
||
}
|
||
|
||
func parseMeijiequanPublishedArticleHTMLLinks(root *nethtml.Node) []MeijiequanPublishedArticle {
|
||
links := meijiequanLinks(root)
|
||
items := make([]MeijiequanPublishedArticle, 0, len(links))
|
||
seen := make(map[string]struct{}, len(links))
|
||
for _, link := range links {
|
||
articleURL := normalizeMeijiequanArticleURL(link.Href)
|
||
if articleURL == "" || !isLikelyPublishedArticleURL(articleURL) {
|
||
continue
|
||
}
|
||
if _, exists := seen[articleURL]; exists {
|
||
continue
|
||
}
|
||
seen[articleURL] = struct{}{}
|
||
items = append(items, MeijiequanPublishedArticle{
|
||
Title: strings.TrimSpace(link.Text),
|
||
ExternalArticleURL: articleURL,
|
||
})
|
||
}
|
||
return items
|
||
}
|
||
|
||
func extractMeijiequanRealBacklink(node *nethtml.Node) string {
|
||
if node == nil {
|
||
return ""
|
||
}
|
||
candidates := make([]string, 0)
|
||
var walk func(*nethtml.Node)
|
||
walk = func(current *nethtml.Node) {
|
||
if current == nil {
|
||
return
|
||
}
|
||
if current.Type == nethtml.ElementNode {
|
||
for _, attr := range current.Attr {
|
||
key := strings.ToLower(strings.TrimSpace(attr.Key))
|
||
value := strings.TrimSpace(attr.Val)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if isMeijiequanBacklinkAttr(key) {
|
||
candidates = append(candidates, value)
|
||
}
|
||
if key == "onclick" || key == "data-clipboard-text" || strings.Contains(key, "url") || strings.Contains(key, "link") || strings.Contains(key, "href") {
|
||
candidates = append(candidates, meijiequanURLPattern.FindAllString(value, -1)...)
|
||
}
|
||
}
|
||
}
|
||
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
||
walk(child)
|
||
}
|
||
}
|
||
walk(node)
|
||
for _, candidate := range candidates {
|
||
if normalized := normalizeMeijiequanArticleURL(candidate); normalized != "" && isLikelyPublishedArticleURL(normalized) {
|
||
return normalized
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isMeijiequanBacklinkAttr(key string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||
case "data-clipboard-text",
|
||
"data-url",
|
||
"data-link",
|
||
"data-href",
|
||
"data-backlink",
|
||
"data-article-url",
|
||
"data-copy",
|
||
"copy-url":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func firstMeijiequanHTMLAttr(node *nethtml.Node, keys ...string) string {
|
||
for _, key := range keys {
|
||
if value := strings.TrimSpace(meijiequanAttr(node, key)); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func meijiequanAttr(node *nethtml.Node, key string) string {
|
||
if node == nil {
|
||
return ""
|
||
}
|
||
for _, attr := range node.Attr {
|
||
if strings.EqualFold(strings.TrimSpace(attr.Key), key) {
|
||
return attr.Val
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func meijiequanNodeText(node *nethtml.Node) string {
|
||
if node == nil {
|
||
return ""
|
||
}
|
||
if node.Type == nethtml.TextNode {
|
||
return node.Data
|
||
}
|
||
var builder strings.Builder
|
||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||
if builder.Len() > 0 {
|
||
builder.WriteByte(' ')
|
||
}
|
||
builder.WriteString(meijiequanNodeText(child))
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func normalizeWhitespace(value string) string {
|
||
return strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||
}
|
||
|
||
func collapseMeijiequanCJKSpaces(value string) string {
|
||
replacer := strings.NewReplacer(
|
||
"退稿 原因", "退稿原因",
|
||
"订单 号", "订单号",
|
||
"资源 名称", "资源名称",
|
||
"发稿 时间", "发稿时间",
|
||
"创建 时间", "创建时间",
|
||
)
|
||
value = replacer.Replace(value)
|
||
runes := []rune(value)
|
||
var builder strings.Builder
|
||
for idx, r := range runes {
|
||
if r == ' ' && idx > 0 && idx+1 < len(runes) && isMeijiequanCJKRune(runes[idx-1]) && isMeijiequanCJKRune(runes[idx+1]) {
|
||
continue
|
||
}
|
||
builder.WriteRune(r)
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func isMeijiequanCJKRune(r rune) bool {
|
||
return (r >= '\u4e00' && r <= '\u9fff') ||
|
||
(r >= '\u3400' && r <= '\u4dbf') ||
|
||
(r >= '\uf900' && r <= '\ufaff')
|
||
}
|
||
|
||
func looksLikeMeijiequanPublishedRow(rowText string) bool {
|
||
rowText = strings.TrimSpace(rowText)
|
||
if rowText == "" {
|
||
return false
|
||
}
|
||
if meijiequanLooksLikeArticleHeader(strings.Fields(rowText)) {
|
||
return false
|
||
}
|
||
if extractMeijiequanOrderCodeFromText(rowText) != "" {
|
||
return true
|
||
}
|
||
return strings.Contains(rowText, "已发") ||
|
||
strings.Contains(rowText, "已发布") ||
|
||
strings.Contains(rowText, "已发表") ||
|
||
strings.Contains(rowText, "退稿") ||
|
||
strings.Contains(rowText, "待安排") ||
|
||
strings.Contains(rowText, "待处理") ||
|
||
strings.Contains(rowText, "发布中") ||
|
||
strings.Contains(rowText, "待发布") ||
|
||
strings.Contains(rowText, "查看链接") ||
|
||
strings.Contains(rowText, "回链") ||
|
||
strings.Contains(rowText, "链接") ||
|
||
strings.Contains(strings.ToLower(rowText), "http")
|
||
}
|
||
|
||
func isLikelyPublishedArticleURL(value string) bool {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
if value == "" {
|
||
return false
|
||
}
|
||
if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") && !strings.HasPrefix(value, "//") {
|
||
return false
|
||
}
|
||
return !strings.Contains(value, "/advSupply/") &&
|
||
!strings.Contains(value, "/supply/") &&
|
||
!strings.Contains(value, "/api/") &&
|
||
!strings.Contains(value, "meitidaren.top") &&
|
||
!strings.Contains(value, "meijiequan.com")
|
||
}
|
||
|
||
func normalizeMeijiequanArticleURL(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(value, "//") {
|
||
return "https:" + value
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(value), "http://") && !strings.HasPrefix(strings.ToLower(value), "https://") {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
func normalizeMeijiequanPublishedResourceID(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if idx := strings.Index(value, "_"); idx > 0 {
|
||
value = value[:idx]
|
||
}
|
||
return value
|
||
}
|
||
|
||
func extractMeijiequanResourceIDFromText(value string) string {
|
||
for _, token := range strings.Fields(value) {
|
||
token = strings.Trim(token, " ,,;;[]()()")
|
||
if strings.Contains(token, "_price") || strings.Contains(token, "_priceyc") {
|
||
return normalizeMeijiequanPublishedResourceID(token)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractMeijiequanOrderCodeFromText(value string) string {
|
||
fields := strings.Fields(value)
|
||
for _, field := range fields {
|
||
token := strings.Trim(field, " ::,,;;[]()()")
|
||
if isLikelyMeijiequanOrderCode(token) {
|
||
return token
|
||
}
|
||
}
|
||
for idx, field := range fields {
|
||
if strings.Contains(field, "单号") || strings.Contains(strings.ToLower(field), "order") {
|
||
if idx+1 < len(fields) {
|
||
token := strings.Trim(fields[idx+1], " ::,,;;")
|
||
if isLikelyMeijiequanOrderCode(token) {
|
||
return token
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractMeijiequanOrderCodeFromTexts(texts []string) string {
|
||
for _, text := range texts {
|
||
trimmed := strings.TrimSpace(text)
|
||
if isLikelyMeijiequanOrderCode(trimmed) {
|
||
return trimmed
|
||
}
|
||
}
|
||
for _, text := range texts {
|
||
if code := extractMeijiequanOrderCodeFromText(text); code != "" {
|
||
return code
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isLikelyMeijiequanOrderCode(value string) bool {
|
||
value = strings.ToUpper(strings.TrimSpace(value))
|
||
if len(value) < 5 || len(value) > 64 {
|
||
return false
|
||
}
|
||
if strings.Contains(value, "HTTP") || strings.Contains(value, "://") {
|
||
return false
|
||
}
|
||
if !strings.HasPrefix(value, "WM") {
|
||
return false
|
||
}
|
||
for _, r := range strings.TrimPrefix(value, "WM") {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func extractMeijiequanResourceNameFromTexts(texts []string) string {
|
||
for _, text := range texts {
|
||
trimmed := strings.TrimSpace(text)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
if isMeijiequanOperationText(trimmed) || extractMeijiequanPublishedStatus(trimmed) != "" || strings.Contains(trimmed, "退稿原因") {
|
||
continue
|
||
}
|
||
if strings.Contains(trimmed, "媒体") ||
|
||
strings.Contains(trimmed, "网") ||
|
||
strings.Contains(trimmed, "报") ||
|
||
strings.Contains(trimmed, "园") ||
|
||
strings.Contains(trimmed, "号") {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractMeijiequanPublishedStatus(value string) string {
|
||
for _, status := range []string{"退稿申请中", "已退稿", "退稿中", "已发表", "已发布", "已发", "发布成功", "待安排", "待处理", "发布中", "待发布"} {
|
||
if strings.Contains(value, status) {
|
||
return status
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractMeijiequanArticleTitleFromTexts(texts []string, resourceName string) string {
|
||
for _, text := range texts {
|
||
trimmed := strings.TrimSpace(text)
|
||
if !isMeijiequanArticleTitleCandidate(trimmed, resourceName) {
|
||
continue
|
||
}
|
||
return trimmed
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isMeijiequanArticleTitleCandidate(value, resourceName string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
if strings.EqualFold(value, strings.TrimSpace(resourceName)) {
|
||
return false
|
||
}
|
||
if isLikelyMeijiequanOrderCode(value) || extractMeijiequanPublishedStatus(value) != "" {
|
||
return false
|
||
}
|
||
if isMeijiequanOperationText(value) {
|
||
return false
|
||
}
|
||
if isMeijiequanGenericPublishedLinkText(value) || strings.Contains(value, "订单号") || strings.Contains(value, "资源名称") {
|
||
return false
|
||
}
|
||
if _, err := strconv.ParseFloat(strings.Trim(value, "¥元 "), 64); err == nil {
|
||
return false
|
||
}
|
||
if parseMeijiequanTime(value) != nil {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func isMeijiequanOperationText(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
return strings.Contains(value, "再次发稿") ||
|
||
strings.Contains(value, "申请退稿") ||
|
||
strings.Contains(value, "改稿") ||
|
||
strings.Contains(value, "标签") ||
|
||
strings.Contains(value, "一键复制")
|
||
}
|
||
|
||
func isMeijiequanGenericPublishedLinkText(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return true
|
||
}
|
||
switch value {
|
||
case "查看", "查看链接", "链接", "回链", "打开", "访问", "预览":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func firstSearchOptionsObject(value any) (any, bool) {
|
||
record, ok := value.(map[string]any)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
for _, key := range []string{"data", "result", "list"} {
|
||
if nested, exists := record[key]; exists {
|
||
if _, ok := nested.(map[string]any); ok {
|
||
return nested, true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func parseMeijiequanSearchOptionGroup(key string, value any) (MediaSupplySearchOptionGroup, bool) {
|
||
record, ok := value.(map[string]any)
|
||
if !ok {
|
||
return MediaSupplySearchOptionGroup{}, false
|
||
}
|
||
name := strings.TrimSpace(meijiequanAnyString(firstMapValue(record, "name", "label", "title")))
|
||
if name == "" {
|
||
name = strings.TrimSpace(key)
|
||
}
|
||
values := meijiequanStringList(firstMapValue(record, "list", "options", "values"))
|
||
if len(values) == 0 {
|
||
return MediaSupplySearchOptionGroup{}, false
|
||
}
|
||
return MediaSupplySearchOptionGroup{Key: key, Name: name, List: values}, true
|
||
}
|
||
|
||
func meijiequanStringList(value any) []string {
|
||
var raw []any
|
||
switch typed := value.(type) {
|
||
case []any:
|
||
raw = typed
|
||
case []string:
|
||
out := make([]string, 0, len(typed))
|
||
for _, item := range typed {
|
||
if text := strings.TrimSpace(item); text != "" {
|
||
out = append(out, text)
|
||
}
|
||
}
|
||
return out
|
||
default:
|
||
text := strings.TrimSpace(meijiequanAnyString(value))
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(text, ",")
|
||
raw = make([]any, 0, len(parts))
|
||
for _, part := range parts {
|
||
raw = append(raw, part)
|
||
}
|
||
}
|
||
out := make([]string, 0, len(raw))
|
||
seen := make(map[string]struct{}, len(raw))
|
||
for _, item := range raw {
|
||
text := strings.TrimSpace(meijiequanAnyString(item))
|
||
if text == "" {
|
||
continue
|
||
}
|
||
if _, exists := seen[text]; exists {
|
||
continue
|
||
}
|
||
seen[text] = struct{}{}
|
||
out = append(out, text)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseMeijiequanHiddenInputs(r io.Reader) map[string]string {
|
||
out := map[string]string{}
|
||
tokenizer := nethtml.NewTokenizer(r)
|
||
for {
|
||
tokenType := tokenizer.Next()
|
||
switch tokenType {
|
||
case nethtml.ErrorToken:
|
||
return out
|
||
case nethtml.StartTagToken, nethtml.SelfClosingTagToken:
|
||
token := tokenizer.Token()
|
||
if !strings.EqualFold(token.Data, "input") {
|
||
continue
|
||
}
|
||
attrs := map[string]string{}
|
||
for _, attr := range token.Attr {
|
||
attrs[strings.ToLower(strings.TrimSpace(attr.Key))] = attr.Val
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(attrs["type"]), "hidden") {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(attrs["name"])
|
||
if name == "" {
|
||
name = strings.TrimSpace(attrs["id"])
|
||
}
|
||
if name != "" {
|
||
out[name] = attrs["value"]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func extractMeijiequanUserID(resp *meijiequanLoginResponse) string {
|
||
if resp == nil {
|
||
return ""
|
||
}
|
||
if resp.UserID > 0 {
|
||
return strconv.FormatInt(resp.UserID, 10)
|
||
}
|
||
if value := extractMeijiequanUserIDFromJSON(resp.Data); value != "" {
|
||
return value
|
||
}
|
||
if value := extractMeijiequanUserIDFromJSON(resp.Raw); value != "" {
|
||
return value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func extractMeijiequanUserIDFromJSON(raw json.RawMessage) string {
|
||
if len(raw) == 0 {
|
||
return ""
|
||
}
|
||
var value any
|
||
if err := json.Unmarshal(raw, &value); err != nil {
|
||
return ""
|
||
}
|
||
return extractMeijiequanUserIDFromValue(value)
|
||
}
|
||
|
||
func extractMeijiequanUserIDFromValue(value any) string {
|
||
switch typed := value.(type) {
|
||
case map[string]any:
|
||
for _, key := range []string{"user_id", "uid", "id", "uk"} {
|
||
if out := strings.TrimSpace(meijiequanAnyString(typed[key])); out != "" && out != "0" {
|
||
return out
|
||
}
|
||
}
|
||
for _, key := range []string{"user", "account", "member", "data"} {
|
||
if out := extractMeijiequanUserIDFromValue(typed[key]); out != "" {
|
||
return out
|
||
}
|
||
}
|
||
case []any:
|
||
for _, item := range typed {
|
||
if out := extractMeijiequanUserIDFromValue(item); out != "" {
|
||
return out
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isMeijiequanChallengeMessage(value string) bool {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
if value == "" {
|
||
return false
|
||
}
|
||
return strings.Contains(value, "验证码") ||
|
||
strings.Contains(value, "captcha") ||
|
||
strings.Contains(value, "verification code") ||
|
||
strings.Contains(value, "code error")
|
||
}
|
||
|
||
func maskMeijiequanUsername(username string) string {
|
||
username = strings.TrimSpace(username)
|
||
switch n := len([]rune(username)); {
|
||
case n == 0:
|
||
return ""
|
||
case n <= 4:
|
||
return strings.Repeat("*", n)
|
||
default:
|
||
runes := []rune(username)
|
||
return string(runes[:3]) + strings.Repeat("*", n-5) + string(runes[n-2:])
|
||
}
|
||
}
|
||
|
||
func parseMeijiequanMediaItem(modelID int, item map[string]any) (UpsertSupplierMediaResourceInput, bool) {
|
||
resourceID := strings.TrimSpace(meijiequanAnyString(item["id"]))
|
||
name := strings.TrimSpace(meijiequanAnyString(item["name"]))
|
||
if resourceID == "" || name == "" {
|
||
return UpsertSupplierMediaResourceInput{}, false
|
||
}
|
||
raw, _ := json.Marshal(item)
|
||
costPrices := extractMeijiequanPrices(item)
|
||
costPrice := costPrices["price"]
|
||
if costPrice <= 0 {
|
||
costPrice = costPrices["sale_price"]
|
||
}
|
||
if costPrice <= 0 {
|
||
costPrice = firstPositivePrice(costPrices)
|
||
}
|
||
updatedAt := parseMeijiequanTime(meijiequanAnyString(item["updated_at"]))
|
||
weight := parseOptionalMeijiequanInt(firstMapValue(item, "meitiquanzhong", "baidu_weight", "weight", "quanzhong"))
|
||
return UpsertSupplierMediaResourceInput{
|
||
Supplier: mediaSupplySupplierMeijiequan,
|
||
SupplierResourceID: resourceID,
|
||
ModelID: modelID,
|
||
Name: name,
|
||
Status: meijiequanAnyString(item["status"]),
|
||
CostPriceCents: costPrice,
|
||
CostPrices: costPrices,
|
||
SalePriceLabel: meijiequanAnyString(item["sale_price"]),
|
||
ResourceURL: meijiequanAnyString(firstMapValue(item, "meitianli", "resource_url", "url", "site_url", "link")),
|
||
BaiduWeight: weight,
|
||
ResourceRemark: meijiequanAnyString(firstMapValue(item, "meitibeizhu", "remark", "beizhu", "note", "description")),
|
||
ChannelType: meijiequanAnyString(firstMapValue(item, "pindaoleixing", "channel_type")),
|
||
Region: meijiequanAnyString(firstMapValue(item, "quyu", "region")),
|
||
InclusionEffect: meijiequanAnyString(firstMapValue(item, "shouluxiaoguo", "inclusion_effect")),
|
||
LinkType: meijiequanAnyString(firstMapValue(item, "lianjieleixing", "link_type")),
|
||
PublishRate: meijiequanAnyString(item["publish_rate"]),
|
||
DeliverySpeed: meijiequanAnyString(firstMapValue(item, "chugaosudu", "avg_delivery_speed")),
|
||
SupplierUpdatedAt: updatedAt,
|
||
Raw: raw,
|
||
}, true
|
||
}
|
||
|
||
func parseOptionalMeijiequanInt(value any) *int {
|
||
text := strings.TrimSpace(meijiequanAnyString(value))
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
parsed, err := strconv.Atoi(text)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return &parsed
|
||
}
|
||
|
||
func extractMeijiequanPrices(item map[string]any) map[string]int64 {
|
||
priceKeys := []string{"sale_price", "price", "price_top", "price_union", "pricecar", "priceyc", "priceyccar", "pricegoods"}
|
||
out := make(map[string]int64, len(priceKeys))
|
||
for _, key := range priceKeys {
|
||
if cents, ok := parsePriceCents(item[key]); ok {
|
||
out[key] = cents
|
||
}
|
||
}
|
||
if sale, ok := out["sale_price"]; ok {
|
||
out["price"] = sale
|
||
}
|
||
return out
|
||
}
|
||
|
||
func firstMeijiequanArticlePriceCents(item map[string]any) int64 {
|
||
for _, key := range []string{
|
||
"price", "sale_price", "cost_price", "amount", "money", "jine", "jiage",
|
||
"media_price", "resource_price", "order_price", "total_price",
|
||
} {
|
||
if cents, ok := parsePriceCents(item[key]); ok {
|
||
return cents
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func parsePriceCents(value any) (int64, bool) {
|
||
switch typed := value.(type) {
|
||
case nil:
|
||
return 0, false
|
||
case float64:
|
||
if typed <= 0 {
|
||
return 0, false
|
||
}
|
||
return int64(typed*100 + 0.5), true
|
||
case json.Number:
|
||
f, err := typed.Float64()
|
||
if err != nil || f <= 0 {
|
||
return 0, false
|
||
}
|
||
return int64(f*100 + 0.5), true
|
||
case string:
|
||
trimmed := strings.TrimSpace(typed)
|
||
if trimmed == "" || trimmed == "--" {
|
||
return 0, false
|
||
}
|
||
trimmed = strings.TrimPrefix(trimmed, "¥")
|
||
trimmed = strings.TrimPrefix(trimmed, "¥")
|
||
trimmed = strings.TrimSpace(strings.ReplaceAll(trimmed, ",", ""))
|
||
f, err := strconv.ParseFloat(trimmed, 64)
|
||
if err != nil || f <= 0 {
|
||
return 0, false
|
||
}
|
||
return int64(f*100 + 0.5), true
|
||
default:
|
||
return parsePriceCents(fmt.Sprint(typed))
|
||
}
|
||
}
|
||
|
||
func meijiequanAnyString(value any) string {
|
||
switch typed := value.(type) {
|
||
case nil:
|
||
return ""
|
||
case string:
|
||
return strings.TrimSpace(typed)
|
||
case float64:
|
||
if typed == float64(int64(typed)) {
|
||
return strconv.FormatInt(int64(typed), 10)
|
||
}
|
||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||
case json.Number:
|
||
return typed.String()
|
||
default:
|
||
return strings.TrimSpace(fmt.Sprint(typed))
|
||
}
|
||
}
|
||
|
||
func meijiequanFlexibleInt64(value any) int64 {
|
||
switch typed := value.(type) {
|
||
case nil:
|
||
return 0
|
||
case int:
|
||
return int64(typed)
|
||
case int64:
|
||
return typed
|
||
case float64:
|
||
return int64(typed)
|
||
case json.Number:
|
||
parsed, err := typed.Int64()
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
f, err := typed.Float64()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return int64(f)
|
||
case string:
|
||
trimmed := strings.TrimSpace(typed)
|
||
if trimmed == "" {
|
||
return 0
|
||
}
|
||
parsed, err := strconv.ParseInt(trimmed, 10, 64)
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
f, err := strconv.ParseFloat(trimmed, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return int64(f)
|
||
default:
|
||
return meijiequanFlexibleInt64(fmt.Sprint(typed))
|
||
}
|
||
}
|
||
|
||
func firstMapValue(item map[string]any, keys ...string) any {
|
||
for _, key := range keys {
|
||
if value, ok := item[key]; ok && strings.TrimSpace(meijiequanAnyString(value)) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func firstPositivePrice(prices map[string]int64) int64 {
|
||
for _, value := range prices {
|
||
if value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func parseMeijiequanTime(value string) *time.Time {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
layouts := []string{
|
||
time.RFC3339,
|
||
"2006-01-02 15:04:05",
|
||
"2006-01-02",
|
||
}
|
||
for _, layout := range layouts {
|
||
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||
utc := parsed.UTC()
|
||
return &utc
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func mimeExt(contentType string) string {
|
||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
extensions, err := mime.ExtensionsByType(mediaType)
|
||
if err != nil || len(extensions) == 0 {
|
||
return ""
|
||
}
|
||
return extensions[0]
|
||
}
|
||
|
||
func compactJSON(raw json.RawMessage) json.RawMessage {
|
||
if len(raw) == 0 {
|
||
return json.RawMessage(`{}`)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := json.Compact(&buf, raw); err != nil {
|
||
return raw
|
||
}
|
||
return buf.Bytes()
|
||
}
|