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 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.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), " 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() }