305 lines
11 KiB
Go
305 lines
11 KiB
Go
package douyin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
StateSucceeded = "succeeded"
|
|
StatePolicyHold = "policy_hold"
|
|
StateNeedsConfirmation = "needs_confirmation"
|
|
|
|
ReasonAuthInvalid = "douyin_auth_invalid"
|
|
ReasonForbidden = "douyin_forbidden"
|
|
ReasonRateLimited = "douyin_rate_limited"
|
|
ReasonChallenge = "douyin_challenge"
|
|
ReasonUnknown = "douyin_result_unknown"
|
|
ReasonIdentityMatch = "douyin_identity_mismatch"
|
|
ReasonSucceeded = "douyin_sync_succeeded"
|
|
|
|
identityEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/self/"
|
|
worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/"
|
|
)
|
|
|
|
var (
|
|
keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
|
|
credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`)
|
|
)
|
|
|
|
var ErrInvalid = errors.New("invalid douyin connector input")
|
|
|
|
type Challenge string
|
|
|
|
const (
|
|
ChallengeNone Challenge = ""
|
|
ChallengeCaptcha Challenge = "captcha"
|
|
ChallengeDevice Challenge = "device"
|
|
)
|
|
|
|
type Cookie struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
Domain string `json:"domain"`
|
|
Path string `json:"path"`
|
|
Secure bool `json:"secure,omitempty"`
|
|
HTTPOnly bool `json:"http_only,omitempty"`
|
|
SameSite string `json:"same_site,omitempty"`
|
|
Expires float64 `json:"expires,omitempty"`
|
|
}
|
|
|
|
type Response struct {
|
|
Status int
|
|
Body []byte
|
|
Challenge Challenge
|
|
}
|
|
|
|
// Browser is the deliberately narrow contract the restricted browser control
|
|
// plane must implement. It does not permit arbitrary CDP commands.
|
|
type Browser interface {
|
|
SetCookies(context.Context, []Cookie) error
|
|
Get(context.Context, string) (Response, error)
|
|
}
|
|
|
|
type SecretReference struct {
|
|
Provider string
|
|
Key string
|
|
}
|
|
|
|
type SecretResolver interface {
|
|
Resolve(context.Context, SecretReference) ([]byte, error)
|
|
}
|
|
|
|
type Work struct {
|
|
ID string `json:"id"`
|
|
Description string `json:"description"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
DiggCount int64 `json:"digg_count"`
|
|
CommentCount int64 `json:"comment_count"`
|
|
ShareCount int64 `json:"share_count"`
|
|
PlayCount int64 `json:"play_count"`
|
|
}
|
|
|
|
type Evidence struct {
|
|
Phase string `json:"phase"`
|
|
HTTPStatus int `json:"http_status,omitempty"`
|
|
IdentityVerified bool `json:"identity_verified"`
|
|
WorksSeen int `json:"works_seen,omitempty"`
|
|
HasMore bool `json:"has_more,omitempty"`
|
|
}
|
|
|
|
type Result struct {
|
|
State string `json:"state"`
|
|
ReasonCode string `json:"reason_code"`
|
|
Evidence Evidence `json:"evidence"`
|
|
}
|
|
|
|
// Store owns both persistence/audit and fail-closed account/runtime handling.
|
|
// Hold must pause the account and stop its existing bound runtime without retry.
|
|
type Store interface {
|
|
Complete(context.Context, string, []Work, Evidence) error
|
|
Hold(context.Context, string, string, string, Evidence) error
|
|
}
|
|
|
|
type Connector struct {
|
|
Browser Browser
|
|
Secrets SecretResolver
|
|
Store Store
|
|
}
|
|
|
|
type Request struct {
|
|
AccountID string
|
|
PlatformAccountKey string
|
|
Credential SecretReference
|
|
}
|
|
|
|
func (connector Connector) Sync(ctx context.Context, request Request) (Result, error) {
|
|
if connector.Browser == nil || connector.Secrets == nil || connector.Store == nil || !keyPattern.MatchString(request.AccountID) ||
|
|
!keyPattern.MatchString(request.PlatformAccountKey) ||
|
|
(request.Credential.Provider != "os_keyring" && request.Credential.Provider != "secret_manager") ||
|
|
!credentialKeyPattern.MatchString(request.Credential.Key) {
|
|
return Result{}, ErrInvalid
|
|
}
|
|
credential, err := connector.Secrets.Resolve(ctx, request.Credential)
|
|
if err != nil {
|
|
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
|
}
|
|
cookies, err := parseCredential(credential)
|
|
if err != nil {
|
|
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
|
}
|
|
if err := connector.Browser.SetCookies(ctx, cookies); err != nil {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "login"})
|
|
}
|
|
|
|
identityResponse, err := connector.Browser.Get(ctx, identityEndpoint)
|
|
if err != nil {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "identity"})
|
|
}
|
|
if state, reason := classify(identityResponse); state != "" {
|
|
return connector.stop(ctx, request.AccountID, state, reason, Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
|
}
|
|
identity, ok := parseIdentity(identityResponse.Body)
|
|
if !ok {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown,
|
|
Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
|
}
|
|
if request.PlatformAccountKey != identity.User.UID && request.PlatformAccountKey != identity.User.SecUID &&
|
|
request.PlatformAccountKey != identity.User.UniqueID {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonIdentityMatch,
|
|
Evidence{Phase: "identity", HTTPStatus: identityResponse.Status})
|
|
}
|
|
|
|
query := url.Values{"sec_user_id": {identity.User.SecUID}, "count": {"20"}, "max_cursor": {"0"}}
|
|
worksResponse, err := connector.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
|
if err != nil {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown,
|
|
Evidence{Phase: "works", IdentityVerified: true})
|
|
}
|
|
if state, reason := classify(worksResponse); state != "" {
|
|
return connector.stop(ctx, request.AccountID, state, reason,
|
|
Evidence{Phase: "works", HTTPStatus: worksResponse.Status, IdentityVerified: true})
|
|
}
|
|
works, hasMore, ok := parseWorks(worksResponse.Body)
|
|
evidence := Evidence{Phase: "works", HTTPStatus: worksResponse.Status, IdentityVerified: true, WorksSeen: len(works), HasMore: hasMore}
|
|
if !ok {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
|
}
|
|
if err := connector.Store.Complete(ctx, request.AccountID, works, evidence); err != nil {
|
|
result, holdErr := connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
|
return result, errors.Join(err, holdErr)
|
|
}
|
|
return Result{State: StateSucceeded, ReasonCode: ReasonSucceeded, Evidence: evidence}, nil
|
|
}
|
|
|
|
func (connector Connector) stop(ctx context.Context, accountID, state, reason string, evidence Evidence) (Result, error) {
|
|
result := Result{State: state, ReasonCode: reason, Evidence: evidence}
|
|
holdContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
defer cancel()
|
|
return result, connector.Store.Hold(holdContext, accountID, state, reason, evidence)
|
|
}
|
|
|
|
func classify(response Response) (string, string) {
|
|
switch response.Challenge {
|
|
case ChallengeCaptcha, ChallengeDevice:
|
|
return StateNeedsConfirmation, ReasonChallenge
|
|
case ChallengeNone:
|
|
default:
|
|
return StateNeedsConfirmation, ReasonUnknown
|
|
}
|
|
switch response.Status {
|
|
case http.StatusUnauthorized:
|
|
return StatePolicyHold, ReasonAuthInvalid
|
|
case http.StatusForbidden:
|
|
return StatePolicyHold, ReasonForbidden
|
|
case http.StatusTooManyRequests:
|
|
return StatePolicyHold, ReasonRateLimited
|
|
case http.StatusOK:
|
|
return "", ""
|
|
default:
|
|
return StateNeedsConfirmation, ReasonUnknown
|
|
}
|
|
}
|
|
|
|
func parseCredential(raw []byte) ([]Cookie, error) {
|
|
if len(raw) == 0 || len(raw) > 64<<10 {
|
|
return nil, ErrInvalid
|
|
}
|
|
var bundle struct {
|
|
Cookies []Cookie `json:"cookies"`
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&bundle); err != nil || len(bundle.Cookies) == 0 || len(bundle.Cookies) > 64 {
|
|
return nil, ErrInvalid
|
|
}
|
|
var trailing json.RawMessage
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return nil, ErrInvalid
|
|
}
|
|
for index := range bundle.Cookies {
|
|
cookie := &bundle.Cookies[index]
|
|
cookie.Domain = strings.ToLower(strings.TrimSpace(cookie.Domain))
|
|
if cookie.Path == "" {
|
|
cookie.Path = "/"
|
|
}
|
|
if cookie.Name == "" || len(cookie.Name) > 256 || len(cookie.Value) > 4096 || len(cookie.Domain) > 256 || len(cookie.Path) > 256 ||
|
|
cookie.Expires < 0 || strings.ContainsAny(cookie.Name, ";\r\n\x00") || strings.ContainsAny(cookie.Value, ";\r\n\x00") ||
|
|
(cookie.Domain != "douyin.com" && !strings.HasSuffix(cookie.Domain, ".douyin.com")) ||
|
|
!strings.HasPrefix(cookie.Path, "/") || strings.ContainsAny(cookie.Path, ";\r\n\x00") ||
|
|
(cookie.SameSite != "" && cookie.SameSite != "Lax" &&
|
|
cookie.SameSite != "Strict" && cookie.SameSite != "None") {
|
|
return nil, ErrInvalid
|
|
}
|
|
}
|
|
return bundle.Cookies, nil
|
|
}
|
|
|
|
type identityEnvelope struct {
|
|
StatusCode *int `json:"status_code"`
|
|
User *struct {
|
|
UID string `json:"uid"`
|
|
SecUID string `json:"sec_uid"`
|
|
UniqueID string `json:"unique_id"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
func parseIdentity(body []byte) (identityEnvelope, bool) {
|
|
var identity identityEnvelope
|
|
if len(body) > 1<<20 || json.Unmarshal(body, &identity) != nil || identity.StatusCode == nil || *identity.StatusCode != 0 || identity.User == nil ||
|
|
!keyPattern.MatchString(identity.User.UID) || !keyPattern.MatchString(identity.User.SecUID) ||
|
|
(identity.User.UniqueID != "" && !keyPattern.MatchString(identity.User.UniqueID)) {
|
|
return identityEnvelope{}, false
|
|
}
|
|
return identity, true
|
|
}
|
|
|
|
type worksEnvelope struct {
|
|
StatusCode *int `json:"status_code"`
|
|
HasMore *bool `json:"has_more"`
|
|
Works []struct {
|
|
ID string `json:"aweme_id"`
|
|
Description string `json:"desc"`
|
|
CreatedAt *int64 `json:"create_time"`
|
|
Statistics *struct {
|
|
DiggCount *int64 `json:"digg_count"`
|
|
CommentCount *int64 `json:"comment_count"`
|
|
ShareCount *int64 `json:"share_count"`
|
|
PlayCount *int64 `json:"play_count"`
|
|
} `json:"statistics"`
|
|
} `json:"aweme_list"`
|
|
}
|
|
|
|
func parseWorks(body []byte) ([]Work, bool, bool) {
|
|
var envelope worksEnvelope
|
|
if len(body) > 4<<20 || json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 ||
|
|
envelope.HasMore == nil || envelope.Works == nil || len(envelope.Works) > 20 {
|
|
return nil, false, false
|
|
}
|
|
works := make([]Work, 0, len(envelope.Works))
|
|
seen := make(map[string]bool, len(envelope.Works))
|
|
for _, candidate := range envelope.Works {
|
|
if !keyPattern.MatchString(candidate.ID) || seen[candidate.ID] || candidate.CreatedAt == nil || *candidate.CreatedAt <= 0 ||
|
|
candidate.Statistics == nil || candidate.Statistics.DiggCount == nil || candidate.Statistics.CommentCount == nil ||
|
|
candidate.Statistics.ShareCount == nil || candidate.Statistics.PlayCount == nil ||
|
|
utf8.RuneCountInString(candidate.Description) > 4096 || *candidate.Statistics.DiggCount < 0 ||
|
|
*candidate.Statistics.CommentCount < 0 || *candidate.Statistics.ShareCount < 0 || *candidate.Statistics.PlayCount < 0 {
|
|
return nil, false, false
|
|
}
|
|
seen[candidate.ID] = true
|
|
works = append(works, Work{ID: candidate.ID, Description: candidate.Description, CreatedAt: *candidate.CreatedAt,
|
|
DiggCount: *candidate.Statistics.DiggCount, CommentCount: *candidate.Statistics.CommentCount,
|
|
ShareCount: *candidate.Statistics.ShareCount, PlayCount: *candidate.Statistics.PlayCount})
|
|
}
|
|
return works, *envelope.HasMore, true
|
|
}
|