Files
geo/server/internal/tenant/app/meijiequan_client.go
T
root a406971187
Frontend CI / Frontend (push) Successful in 2m55s
Backend CI / Backend (push) Failing after 8m41s
fix: implement resource reconciliation and update handling in media supply favorites
2026-06-23 23:34:45 +08:00

2766 lines
79 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}