feat: finish Douyin production readiness flow
This commit is contained in:
@@ -363,6 +363,21 @@ func (s *Store) GetWork(ctx context.Context, id string) (Work, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkByKey(ctx context.Context, platform, workKey string) (Work, error) {
|
||||
platform, workKey = strings.TrimSpace(platform), strings.TrimSpace(workKey)
|
||||
if !ValidatePlatform(platform) || workKey == "" {
|
||||
return Work{}, ErrInvalid
|
||||
}
|
||||
result, err := scanWork(s.db.QueryRowContext(ctx, workSelect+` WHERE platform = $1 AND work_key = $2`, platform, workKey))
|
||||
if err != nil {
|
||||
return Work{}, rowError(err)
|
||||
}
|
||||
if err := s.loadWorkSources(ctx, &result); err != nil {
|
||||
return Work{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error) {
|
||||
query, args := workSelect, make([]any, 0, 8)
|
||||
where := make([]string, 0, 7)
|
||||
@@ -687,6 +702,15 @@ func (s *Store) GetComment(ctx context.Context, id string) (Comment, error) {
|
||||
return result, rowError(err)
|
||||
}
|
||||
|
||||
func (s *Store) GetCommentByKey(ctx context.Context, platform, commentKey string) (Comment, error) {
|
||||
platform, commentKey = strings.TrimSpace(platform), strings.TrimSpace(commentKey)
|
||||
if !ValidatePlatform(platform) || commentKey == "" {
|
||||
return Comment{}, ErrInvalid
|
||||
}
|
||||
result, err := scanComment(s.db.QueryRowContext(ctx, commentSelect+` WHERE platform = $1 AND comment_key = $2`, platform, commentKey))
|
||||
return result, rowError(err)
|
||||
}
|
||||
|
||||
func pageBounds(page, pageSize int) (int, int, error) {
|
||||
if page < 1 || pageSize < 1 || pageSize > 100 {
|
||||
return 0, 0, ErrInvalid
|
||||
|
||||
@@ -101,6 +101,8 @@ func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *Store) SetSecretBridge(bridge SecretBridge) { s.secrets = bridge }
|
||||
|
||||
func (s *Store) automaticExecutionLock(accountID string) *sync.Mutex {
|
||||
|
||||
@@ -32,6 +32,10 @@ const (
|
||||
worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/"
|
||||
)
|
||||
|
||||
func douyinAPIQuery() url.Values {
|
||||
return url.Values{"aid": {"6383"}, "device_platform": {"webapp"}}
|
||||
}
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
|
||||
|
||||
var ErrInvalid = errors.New("invalid douyin connector input")
|
||||
@@ -156,7 +160,10 @@ func (connector Connector) Sync(ctx context.Context, request Request) (Result, e
|
||||
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)}}
|
||||
query := douyinAPIQuery()
|
||||
query.Set("sec_user_id", identity.User.SecUID)
|
||||
query.Set("count", "20")
|
||||
query.Set("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)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,7 +12,10 @@ import (
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
)
|
||||
|
||||
const commentsEndpoint = "https://www.douyin.com/aweme/v1/web/comment/list/"
|
||||
const (
|
||||
commentsEndpoint = "https://www.douyin.com/aweme/v1/web/comment/list/"
|
||||
profileOtherEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/other/"
|
||||
)
|
||||
|
||||
type CreatorCollector struct {
|
||||
Browser Browser
|
||||
@@ -30,14 +32,27 @@ func (c CreatorCollector) CanonicalSecUID(ctx context.Context, expectedKey strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := creatorResponseError(response, "identity"); err != nil {
|
||||
return canonicalSecUID(response, expectedKey)
|
||||
}
|
||||
|
||||
func (c CreatorCollector) CanonicalTargetSecUID(ctx context.Context, expectedKey string) (string, error) {
|
||||
if c.Browser == nil || !keyPattern.MatchString(expectedKey) {
|
||||
return "", fmt.Errorf("%w: invalid target identity request", ErrInvalid)
|
||||
}
|
||||
field := "user_id"
|
||||
if _, err := strconv.ParseUint(expectedKey, 10, 64); err != nil {
|
||||
if !strings.HasPrefix(expectedKey, "MS4") {
|
||||
return "", fmt.Errorf("%w: target requires a Douyin UID or sec UID", ErrInvalid)
|
||||
}
|
||||
field = "sec_user_id"
|
||||
}
|
||||
query := douyinAPIQuery()
|
||||
query.Set(field, expectedKey)
|
||||
response, err := c.Browser.Get(ctx, profileOtherEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
identity, ok := parseIdentity(response.Body)
|
||||
if !ok || expectedKey != identity.User.UID && expectedKey != identity.User.SecUID && expectedKey != identity.User.UniqueID {
|
||||
return "", fmt.Errorf("%w: douyin identity mismatch", ErrInvalid)
|
||||
}
|
||||
return identity.User.SecUID, nil
|
||||
return canonicalSecUID(response, expectedKey)
|
||||
}
|
||||
|
||||
func (c CreatorCollector) VerifyIdentity(ctx context.Context, expectedKey string) error {
|
||||
@@ -48,6 +63,17 @@ func (c CreatorCollector) VerifyIdentity(ctx context.Context, expectedKey string
|
||||
return err
|
||||
}
|
||||
|
||||
func canonicalSecUID(response Response, expectedKey string) (string, error) {
|
||||
if err := creatorResponseError(response, "identity"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
identity, ok := parseIdentity(response.Body)
|
||||
if !ok || expectedKey != identity.User.UID && expectedKey != identity.User.SecUID && expectedKey != identity.User.UniqueID {
|
||||
return "", fmt.Errorf("%w: douyin identity mismatch", ErrInvalid)
|
||||
}
|
||||
return identity.User.SecUID, nil
|
||||
}
|
||||
|
||||
func (c CreatorCollector) ListWorks(ctx context.Context, accountKey, cursor string) (creator.WorkPage, error) {
|
||||
if c.AccountKey != "" {
|
||||
accountKey = c.AccountKey
|
||||
@@ -59,7 +85,10 @@ func (c CreatorCollector) ListWorks(ctx context.Context, accountKey, cursor stri
|
||||
if cursor != "" {
|
||||
maxCursor = cursor
|
||||
}
|
||||
query := url.Values{"sec_user_id": {accountKey}, "count": {"20"}, "max_cursor": {maxCursor}}
|
||||
query := douyinAPIQuery()
|
||||
query.Set("sec_user_id", accountKey)
|
||||
query.Set("count", "20")
|
||||
query.Set("max_cursor", maxCursor)
|
||||
response, err := c.Browser.Get(ctx, worksEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return creator.WorkPage{}, err
|
||||
@@ -110,7 +139,10 @@ func (c CreatorCollector) ListTopLevelComments(ctx context.Context, workKey, cur
|
||||
if cursor != "" {
|
||||
cursorValue = cursor
|
||||
}
|
||||
query := url.Values{"aweme_id": {workKey}, "count": {"20"}, "cursor": {cursorValue}}
|
||||
query := douyinAPIQuery()
|
||||
query.Set("aweme_id", workKey)
|
||||
query.Set("count", "20")
|
||||
query.Set("cursor", cursorValue)
|
||||
response, err := c.Browser.Get(ctx, commentsEndpoint+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return creator.CommentPage{}, err
|
||||
@@ -118,8 +150,12 @@ func (c CreatorCollector) ListTopLevelComments(ctx context.Context, workKey, cur
|
||||
if err := creatorResponseError(response, "comments"); err != nil {
|
||||
return creator.CommentPage{}, err
|
||||
}
|
||||
return parseCreatorCommentsPage(response.Body)
|
||||
}
|
||||
|
||||
func parseCreatorCommentsPage(body []byte) (creator.CommentPage, error) {
|
||||
var envelope commentEnvelope
|
||||
if len(response.Body) > 4<<20 || json.Unmarshal(response.Body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 || envelope.HasMore == nil || envelope.Comments == nil {
|
||||
if len(body) > 4<<20 || json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 || envelope.HasMore == nil {
|
||||
return creator.CommentPage{}, fmt.Errorf("%w: invalid douyin comments response", ErrInvalid)
|
||||
}
|
||||
items := make([]creator.CommentInput, 0, len(envelope.Comments))
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
package douyin
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type collectorBrowser struct {
|
||||
response Response
|
||||
url string
|
||||
}
|
||||
|
||||
func (b *collectorBrowser) Get(_ context.Context, target string) (Response, error) {
|
||||
b.url = target
|
||||
return b.response, nil
|
||||
}
|
||||
|
||||
func TestCanonicalTargetSecUIDUsesTargetProfileEndpoint(t *testing.T) {
|
||||
browser := &collectorBrowser{response: Response{Status: 200, Body: []byte(`{"status_code":0,"user":{"uid":"2328120603967913","sec_uid":"MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK","unique_id":"96332518739"}}`)}}
|
||||
collector := CreatorCollector{Browser: browser}
|
||||
secUID, err := collector.CanonicalTargetSecUID(context.Background(), "2328120603967913")
|
||||
if err != nil || secUID != "MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK" {
|
||||
t.Fatalf("canonical target identity: sec_uid=%q err=%v", secUID, err)
|
||||
}
|
||||
parsed, err := url.Parse(browser.url)
|
||||
if err != nil || parsed.Path != "/aweme/v1/web/user/profile/other/" || parsed.Query().Get("user_id") != "2328120603967913" {
|
||||
t.Fatalf("unexpected target identity URL: %s", browser.url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalTargetSecUIDRejectsUniqueIDLookup(t *testing.T) {
|
||||
browser := &collectorBrowser{}
|
||||
_, err := (CreatorCollector{Browser: browser}).CanonicalTargetSecUID(context.Background(), "creator_handle")
|
||||
if err == nil {
|
||||
t.Fatal("expected a numeric target key to be treated as a UID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreatorCommentsPageAllowsEmptyComments(t *testing.T) {
|
||||
page, err := parseCreatorCommentsPage([]byte(`{"status_code":0,"has_more":false,"cursor":20,"comments":null}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.HasMore || page.NextCursor != "20" || len(page.Items) != 0 {
|
||||
t.Fatalf("unexpected empty comments page: %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreatorWorksPageKeepsPartialMetadata(t *testing.T) {
|
||||
body := []byte(`{"status_code":0,"has_more":false,"aweme_list":[{"aweme_id":"123","desc":"partial","statistics":{"digg_count":7}}]}`)
|
||||
|
||||
@@ -144,6 +144,8 @@ func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *Store) SetTaskNotifier(notify taskstate.Notifier) { s.notify = notify }
|
||||
|
||||
func (s *Store) notifyTransitions(transitions []taskstate.Transition) {
|
||||
|
||||
@@ -229,6 +229,8 @@ func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *Store) SetTaskNotifier(notify taskstate.Notifier) { s.notify = notify }
|
||||
|
||||
func (s *Store) notifyTransitions(transitions []taskstate.Transition) {
|
||||
|
||||
Reference in New Issue
Block a user