feat: finish Douyin production readiness flow

This commit is contained in:
2026-09-15 09:20:51 +08:00
parent 5867aac20f
commit 977e541fef
28 changed files with 1257 additions and 118 deletions
+24
View File
@@ -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
+2
View File
@@ -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 {
+8 -1
View File
@@ -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)
+47 -11
View File
@@ -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))
+46 -1
View File
@@ -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}}]}`)
+2
View File
@@ -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) {
+2
View File
@@ -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) {