2026-05-29 23:17:01 +08:00
|
|
|
|
package app
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"mime"
|
2026-06-03 22:11:22 +08:00
|
|
|
|
"mime/multipart"
|
2026-05-29 23:17:01 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/http/cookiejar"
|
2026-06-03 22:11:22 +08:00
|
|
|
|
"net/textproto"
|
2026-05-29 23:17:01 +08:00
|
|
|
|
"net/url"
|
|
|
|
|
|
"regexp"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
goredis "github.com/redis/go-redis/v9"
|
|
|
|
|
|
"github.com/yuin/goldmark"
|
2026-06-03 15:49:01 +08:00
|
|
|
|
"github.com/yuin/goldmark/extension"
|
|
|
|
|
|
goldhtml "github.com/yuin/goldmark/renderer/html"
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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
|
2026-06-02 14:50:36 +08:00
|
|
|
|
PriceCents int64
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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:"-"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:11:22 +08:00
|
|
|
|
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:"-"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 22:11:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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
|
2026-06-03 15:49:01 +08:00
|
|
|
|
renderer := goldmark.New(
|
|
|
|
|
|
goldmark.WithExtensions(extension.GFM),
|
|
|
|
|
|
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
|
|
|
|
|
|
)
|
|
|
|
|
|
if err := renderer.Convert([]byte(markdown), &output); err != nil {
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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 := ""
|
2026-06-03 15:49:01 +08:00
|
|
|
|
originalSrc := ""
|
|
|
|
|
|
dataSrc := ""
|
|
|
|
|
|
attrs := make([]nethtml.Attribute, 0, len(node.Attr)+1)
|
2026-05-29 23:17:01 +08:00
|
|
|
|
for _, attr := range node.Attr {
|
2026-06-03 15:49:01 +08:00
|
|
|
|
switch strings.ToLower(strings.TrimSpace(attr.Key)) {
|
2026-05-29 23:17:01 +08:00
|
|
|
|
case "src":
|
|
|
|
|
|
src = strings.TrimSpace(attr.Val)
|
|
|
|
|
|
case "_src":
|
2026-06-03 15:49:01 +08:00
|
|
|
|
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})
|
2026-05-29 23:17:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 15:49:01 +08:00
|
|
|
|
node.Attr = attrs
|
2026-05-29 23:17:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
|
|
|
|
addMeijiequanImageSrcAttribute(child)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:49:01 +08:00
|
|
|
|
func meijiequanHTMLAttrsHaveKey(attrs []nethtml.Attribute, key string) bool {
|
|
|
|
|
|
for _, attr := range attrs {
|
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(attr.Key), key) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-06-02 14:50:36 +08:00
|
|
|
|
orderCode := strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "order_code", "code", "sn", "order_sn", "dingdanhao", "order_no", "orderno", "danhao")))
|
|
|
|
|
|
if !isLikelyMeijiequanOrderCode(orderCode) {
|
|
|
|
|
|
orderCode = firstMeijiequanOrderCode(orderCode)
|
|
|
|
|
|
}
|
2026-05-29 23:17:01 +08:00
|
|
|
|
raw, _ := json.Marshal(item)
|
|
|
|
|
|
return MeijiequanPublishedArticle{
|
|
|
|
|
|
OrderID: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "id", "order_id", "article_id"))),
|
2026-06-02 14:50:36 +08:00
|
|
|
|
OrderCode: orderCode,
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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"))),
|
2026-06-02 14:50:36 +08:00
|
|
|
|
PriceCents: firstMeijiequanArticlePriceCents(item),
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-06-02 14:50:36 +08:00
|
|
|
|
priceCents, _ := parsePriceCents(columnValues["价格"])
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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,
|
2026-06-02 14:50:36 +08:00
|
|
|
|
PriceCents: priceCents,
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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 "资源名称"
|
2026-06-02 14:50:36 +08:00
|
|
|
|
case strings.Contains(value, "价格") || strings.Contains(value, "金额"):
|
|
|
|
|
|
return "价格"
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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) {
|
2026-06-02 14:50:36 +08:00
|
|
|
|
token := strings.Trim(fields[idx+1], " ::,,;;")
|
|
|
|
|
|
if isLikelyMeijiequanOrderCode(token) {
|
|
|
|
|
|
return token
|
|
|
|
|
|
}
|
2026-05-29 23:17:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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))
|
2026-06-02 14:50:36 +08:00
|
|
|
|
if len(value) < 5 || len(value) > 64 {
|
2026-05-29 23:17:01 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(value, "HTTP") || strings.Contains(value, "://") {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-06-02 14:50:36 +08:00
|
|
|
|
if !strings.HasPrefix(value, "WM") {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, r := range strings.TrimPrefix(value, "WM") {
|
|
|
|
|
|
if r < '0' || r > '9' {
|
2026-05-29 23:17:01 +08:00
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-02 14:50:36 +08:00
|
|
|
|
return true
|
2026-05-29 23:17:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 14:50:36 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 23:17:01 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|