363 lines
13 KiB
Go
363 lines
13 KiB
Go
package douyin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"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/?aid=6383&device_platform=webapp"
|
|
worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/"
|
|
)
|
|
|
|
var keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
|
|
|
|
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. Login happens in the managed browser session; the
|
|
// connector never injects stored credentials or cookies.
|
|
type Browser interface {
|
|
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"`
|
|
Pages int `json:"pages,omitempty"`
|
|
PaginationComplete bool `json:"pagination_complete"`
|
|
}
|
|
|
|
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.Store == nil || !keyPattern.MatchString(request.AccountID) ||
|
|
!keyPattern.MatchString(request.PlatformAccountKey) {
|
|
return Result{}, ErrInvalid
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
|
}
|
|
identityResponse, err := connector.Browser.Get(ctx, identityEndpoint)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"})
|
|
}
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "login"})
|
|
}
|
|
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})
|
|
}
|
|
|
|
var works []Work
|
|
seen := make(map[string]struct{})
|
|
evidence := Evidence{Phase: "works", IdentityVerified: true}
|
|
cursor := int64(0)
|
|
for page := 0; page < 100; page++ {
|
|
query := url.Values{"sec_user_id": {identity.User.SecUID}, "count": {"20"}, "max_cursor": {strconv.FormatInt(cursor, 10)}}
|
|
worksResponse, err := connector.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
|
if err != nil {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
|
}
|
|
if state, reason := classify(worksResponse); state != "" {
|
|
evidence.HTTPStatus = worksResponse.Status
|
|
return connector.stop(ctx, request.AccountID, state, reason, evidence)
|
|
}
|
|
pageWorks, hasMore, nextCursor, ok := parseWorksPage(worksResponse.Body)
|
|
evidence.HTTPStatus, evidence.Pages, evidence.HasMore = worksResponse.Status, page+1, hasMore
|
|
if !ok {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
|
}
|
|
for _, work := range pageWorks {
|
|
if _, exists := seen[work.ID]; exists {
|
|
continue
|
|
}
|
|
seen[work.ID] = struct{}{}
|
|
works = append(works, work)
|
|
}
|
|
if !hasMore {
|
|
evidence.PaginationComplete = true
|
|
break
|
|
}
|
|
if nextCursor == nil {
|
|
// Older gateway responses omitted the cursor. Keep the existing
|
|
// read-only behavior, but expose that pagination was incomplete.
|
|
break
|
|
}
|
|
if *nextCursor <= cursor {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
|
}
|
|
cursor = *nextCursor
|
|
if page == 99 {
|
|
return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, evidence)
|
|
}
|
|
}
|
|
evidence.WorksSeen = len(works)
|
|
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
|
|
}
|
|
}
|
|
|
|
// ParseCredential accepts either the persisted JSON cookie bundle or the
|
|
// manually captured Cookie header used by an already logged-in browser.
|
|
func ParseCredential(raw []byte) ([]Cookie, error) {
|
|
if cookies, err := ParseCookieHeader(raw); err == nil {
|
|
return cookies, nil
|
|
}
|
|
return ParseCookieBundle(raw)
|
|
}
|
|
|
|
func ParseCookieBundle(raw []byte) ([]Cookie, error) {
|
|
return parseCredential(raw)
|
|
}
|
|
|
|
func ParseCookieHeader(raw []byte) ([]Cookie, error) {
|
|
if len(raw) == 0 || len(raw) > 64<<10 {
|
|
return nil, ErrInvalid
|
|
}
|
|
parsed, err := http.ParseCookie(string(raw))
|
|
if err != nil || len(parsed) == 0 || len(parsed) > 64 {
|
|
return nil, ErrInvalid
|
|
}
|
|
cookies := make([]Cookie, 0, len(parsed))
|
|
for _, cookie := range parsed {
|
|
if cookie == nil || cookie.Name == "" || cookie.Value == "" {
|
|
return nil, ErrInvalid
|
|
}
|
|
cookies = append(cookies, Cookie{Name: cookie.Name, Value: cookie.Value, Domain: ".douyin.com", Path: "/", Secure: true, SameSite: "Lax"})
|
|
}
|
|
return cookies, nil
|
|
}
|
|
|
|
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"`
|
|
MaxCursor *int64 `json:"max_cursor"`
|
|
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 parseWorksPage(body []byte) ([]Work, bool, *int64, 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 || envelope.MaxCursor != nil && *envelope.MaxCursor < 0 {
|
|
return nil, false, nil, 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, nil, 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, envelope.MaxCursor, true
|
|
}
|
|
|
|
func parseWorks(body []byte) ([]Work, bool, bool) {
|
|
works, hasMore, _, ok := parseWorksPage(body)
|
|
return works, hasMore, ok
|
|
}
|