feat: restore Xiaohongshu read-only collection

This commit is contained in:
2026-09-14 20:05:32 +08:00
parent 44a28954cf
commit 4c37d0c9bf
13 changed files with 1539 additions and 39 deletions
+657
View File
@@ -0,0 +1,657 @@
package xiaohongshu
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"git.ipao.vip/rogee/creator-hub/internal/creator"
)
const (
APIOrigin = "https://edith.xiaohongshu.com"
SearchOrigin = "https://so.xiaohongshu.com"
IdentityURL = APIOrigin + "/api/sns/web/v2/user/me"
UserPostedPath = "/api/sns/web/v1/user_posted"
CommentsPath = "/api/sns/web/v2/comment/page"
SearchNotesPath = "/api/sns/web/v2/search/notes"
FeedPath = "/api/sns/web/v1/feed"
)
var keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
type Response struct {
Status int
Body []byte
Challenge string
}
type Browser interface {
Get(context.Context, string) (Response, error)
Post(context.Context, string, []byte) (Response, error)
}
type Identity struct {
UserID string
Nickname string
}
type Collector struct {
Browser Browser
AccountKey string
SourceType string
SourceID string
HomepageURL string
contexts sync.Map
}
type accessContext struct {
Token string
Source string
}
func (c *Collector) VerifyIdentity(ctx context.Context, expectedKey string) error {
_, err := c.Identity(ctx, expectedKey)
return err
}
func (c *Collector) Identity(ctx context.Context, expectedKey string) (Identity, error) {
if c == nil || c.Browser == nil || !keyPattern.MatchString(expectedKey) {
return Identity{}, fmt.Errorf("%w: invalid xiaohongshu identity request", creator.ErrInvalid)
}
response, err := c.Browser.Get(ctx, IdentityURL)
if err != nil {
return Identity{}, err
}
if err := responseError(response, "identity"); err != nil {
return Identity{}, err
}
identity, ok := parseIdentity(response.Body)
if !ok || identity.UserID != expectedKey {
return Identity{}, fmt.Errorf("%w: xiaohongshu identity mismatch", creator.ErrConflict)
}
return identity, nil
}
func (c *Collector) ListWorks(ctx context.Context, accountKey, cursor string) (creator.WorkPage, error) {
if c == nil {
return creator.WorkPage{}, fmt.Errorf("%w: xiaohongshu collector is nil", creator.ErrUnavailable)
}
if c.AccountKey != "" {
accountKey = c.AccountKey
}
if c.Browser == nil || !keyPattern.MatchString(accountKey) || !validCursor(cursor) {
return creator.WorkPage{}, fmt.Errorf("%w: invalid xiaohongshu work collection request", creator.ErrInvalid)
}
access := c.defaultContext()
query := url.Values{
"user_id": {accountKey},
"cursor": {cursor},
"num": {"30"},
"image_formats": {"jpg,webp,avif"},
"xsec_source": {access.Source},
"xsec_token": {access.Token},
}
response, err := c.Browser.Get(ctx, APIOrigin+UserPostedPath+"?"+query.Encode())
if err != nil {
return creator.WorkPage{}, err
}
if err := responseError(response, "works"); err != nil {
return creator.WorkPage{}, err
}
items, nextCursor, hasMore, ok := parseWorksPage(response.Body, c, access)
if !ok {
return creator.WorkPage{}, fmt.Errorf("%w: invalid xiaohongshu works response", creator.ErrInvalid)
}
return creator.WorkPage{Items: items, NextCursor: nextCursor, HasMore: hasMore}, nil
}
func (c *Collector) ListTopLevelComments(ctx context.Context, workKey, cursor string) (creator.CommentPage, error) {
if c == nil || c.Browser == nil || !keyPattern.MatchString(workKey) || !validCursor(cursor) {
return creator.CommentPage{}, fmt.Errorf("%w: invalid xiaohongshu comment collection request", creator.ErrInvalid)
}
access := c.contextFor(workKey)
query := url.Values{
"note_id": {workKey},
"cursor": {cursor},
"top_comment_id": {""},
"image_formats": {"jpg,webp,avif"},
"xsec_source": {access.Source},
"xsec_token": {access.Token},
}
response, err := c.Browser.Get(ctx, APIOrigin+CommentsPath+"?"+query.Encode())
if err != nil {
return creator.CommentPage{}, err
}
if err := responseError(response, "comments"); err != nil {
return creator.CommentPage{}, err
}
items, nextCursor, hasMore, ok := parseCommentsPage(response.Body, workKey)
if !ok {
return creator.CommentPage{}, fmt.Errorf("%w: invalid xiaohongshu comments response", creator.ErrInvalid)
}
return creator.CommentPage{Items: items, NextCursor: nextCursor, HasMore: hasMore}, nil
}
func (c *Collector) SearchNotes(ctx context.Context, queryText string, page int) (creator.WorkPage, error) {
if c == nil || c.Browser == nil || strings.TrimSpace(queryText) == "" || page < 1 || page > 10000 || utf8.RuneCountInString(queryText) > 200 {
return creator.WorkPage{}, fmt.Errorf("%w: invalid xiaohongshu search request", creator.ErrInvalid)
}
searchID, err := randomID()
if err != nil {
return creator.WorkPage{}, fmt.Errorf("create xiaohongshu search id: %w", err)
}
sessionID, err := randomID()
if err != nil {
return creator.WorkPage{}, fmt.Errorf("create xiaohongshu search session id: %w", err)
}
body, err := json.Marshal(map[string]any{
"keyword": queryText,
"page": page,
"page_size": 20,
"search_id": searchID,
"sort": "general",
"note_type": 0,
"ext_flags": []any{},
"geo": "",
"image_formats": []string{"jpg", "webp", "avif"},
"session_id": sessionID,
})
if err != nil {
return creator.WorkPage{}, err
}
response, err := c.Browser.Post(ctx, SearchOrigin+SearchNotesPath, body)
if err != nil {
return creator.WorkPage{}, err
}
if err := responseError(response, "search"); err != nil {
return creator.WorkPage{}, err
}
items, nextCursor, hasMore, ok := parseWorksPage(response.Body, c, c.defaultContext())
if !ok {
return creator.WorkPage{}, fmt.Errorf("%w: invalid xiaohongshu search response", creator.ErrInvalid)
}
return creator.WorkPage{Items: items, NextCursor: nextCursor, HasMore: hasMore}, nil
}
func randomID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
func (c *Collector) defaultContext() accessContext {
access := accessContext{Source: "pc_user"}
if c != nil && strings.TrimSpace(c.HomepageURL) != "" {
parsed, err := url.Parse(c.HomepageURL)
if err == nil {
if token := strings.TrimSpace(parsed.Query().Get("xsec_token")); token != "" {
access.Token = token
}
if source := strings.TrimSpace(parsed.Query().Get("xsec_source")); source != "" {
access.Source = source
}
}
}
return access
}
func (c *Collector) SetWorkContext(workKey, originalURL string) error {
if c == nil || !keyPattern.MatchString(workKey) {
return fmt.Errorf("%w: invalid xiaohongshu work context", creator.ErrInvalid)
}
access := c.defaultContext()
if strings.TrimSpace(originalURL) != "" {
parsed, err := url.Parse(originalURL)
if err != nil || parsed.Scheme != "https" || parsed.Host != "www.xiaohongshu.com" || !strings.Contains(parsed.Path, workKey) {
return fmt.Errorf("%w: invalid xiaohongshu work URL", creator.ErrInvalid)
}
if token := strings.TrimSpace(parsed.Query().Get("xsec_token")); token != "" {
access.Token = token
}
if source := strings.TrimSpace(parsed.Query().Get("xsec_source")); source != "" {
access.Source = source
}
}
if len(access.Token) > 2048 || !keyPattern.MatchString(access.Source) {
return fmt.Errorf("%w: invalid xiaohongshu work context", creator.ErrInvalid)
}
c.contexts.Store(workKey, access)
return nil
}
func (c *Collector) contextFor(workKey string) accessContext {
if c != nil {
if value, ok := c.contexts.Load(workKey); ok {
if access, ok := value.(accessContext); ok {
return access
}
}
}
return c.defaultContext()
}
func parseIdentity(body []byte) (Identity, bool) {
if len(body) == 0 || len(body) > 1<<20 {
return Identity{}, false
}
var envelope struct {
Success *bool `json:"success"`
Data *struct {
UserID string `json:"user_id"`
Nickname string `json:"nickname"`
UserInfo *struct {
UserID string `json:"user_id"`
Nickname string `json:"nickname"`
} `json:"user_info"`
} `json:"data"`
}
if json.Unmarshal(body, &envelope) != nil || envelope.Success == nil || !*envelope.Success || envelope.Data == nil {
return Identity{}, false
}
userID, nickname := envelope.Data.UserID, envelope.Data.Nickname
if envelope.Data.UserInfo != nil {
if userID == "" {
userID = envelope.Data.UserInfo.UserID
}
if nickname == "" {
nickname = envelope.Data.UserInfo.Nickname
}
}
if !keyPattern.MatchString(userID) || utf8.RuneCountInString(nickname) > 256 {
return Identity{}, false
}
return Identity{UserID: userID, Nickname: nickname}, true
}
func parseWorksPage(body []byte, collector *Collector, fallback accessContext) ([]creator.WorkInput, string, bool, bool) {
if len(body) == 0 || len(body) > 4<<20 {
return nil, "", false, false
}
var envelope struct {
Success *bool `json:"success"`
Data *struct {
Cursor json.RawMessage `json:"cursor"`
HasMore *bool `json:"has_more"`
Notes []json.RawMessage `json:"notes"`
Items []json.RawMessage `json:"items"`
} `json:"data"`
}
if json.Unmarshal(body, &envelope) != nil || envelope.Success == nil || !*envelope.Success || envelope.Data == nil || envelope.Data.HasMore == nil {
return nil, "", false, false
}
rawNotes := envelope.Data.Notes
if rawNotes == nil {
rawNotes = envelope.Data.Items
}
if rawNotes == nil || len(rawNotes) > 30 {
return nil, "", false, false
}
items := make([]creator.WorkInput, 0, len(rawNotes))
seen := make(map[string]struct{}, len(rawNotes))
for _, raw := range rawNotes {
item, access, ok := parseWork(raw, fallback)
if !ok {
return nil, "", false, false
}
if _, exists := seen[item.WorkKey]; exists {
return nil, "", false, false
}
seen[item.WorkKey] = struct{}{}
if collector != nil {
item.SourceType = collector.SourceType
if item.SourceType == "" {
item.SourceType = creator.SourceCompetitor
}
item.SourceID = collector.SourceID
if item.SourceID == "" {
item.SourceID = collector.AccountKey
}
collector.contexts.Store(item.WorkKey, access)
}
items = append(items, item)
}
nextCursor, ok := cursorValue(envelope.Data.Cursor)
if !ok {
return nil, "", false, false
}
if *envelope.Data.HasMore && nextCursor == "" {
return nil, "", false, false
}
return items, nextCursor, *envelope.Data.HasMore, true
}
func parseWork(raw json.RawMessage, fallback accessContext) (creator.WorkInput, accessContext, bool) {
object, ok := objectValue(raw)
if !ok {
return creator.WorkInput{}, accessContext{}, false
}
if nested := firstObject(object, "note_card", "noteCard"); nested != nil {
object = nested
}
id := firstString(object, "note_id", "id")
if !keyPattern.MatchString(id) {
return creator.WorkInput{}, accessContext{}, false
}
title := firstString(object, "display_title", "title")
body := firstString(object, "desc", "description", "content")
if utf8.RuneCountInString(title) > 4096 || utf8.RuneCountInString(body) > 100000 {
return creator.WorkInput{}, accessContext{}, false
}
user := firstObject(object, "user", "user_info", "author")
authorName := firstString(user, "nickname", "name")
published, publishedOK := optionalTimestamp(object, "time", "create_time", "last_update_time")
if !publishedOK {
return creator.WorkInput{}, accessContext{}, false
}
likes, likesOK := optionalInt(object, "likes", "liked_count")
comments, commentsOK := optionalInt(object, "comments_count", "comment_count")
shares, sharesOK := optionalInt(object, "shares", "shared_count")
interact := firstObject(object, "interact_info", "interactInfo", "statistics")
if interact != nil {
if likes == nil {
likes, likesOK = optionalInt(interact, "liked_count", "likes")
}
if comments == nil {
comments, commentsOK = optionalInt(interact, "comment_count", "comments_count")
}
if shares == nil {
shares, sharesOK = optionalInt(interact, "shared_count", "shares")
}
}
if !likesOK || !commentsOK || !sharesOK {
return creator.WorkInput{}, accessContext{}, false
}
access := fallback
if token := firstString(object, "xsec_token"); token != "" {
access.Token = token
}
if source := firstString(object, "xsec_source"); source != "" {
access.Source = source
}
if utf8.RuneCountInString(access.Token) > 2048 || !keyPattern.MatchString(access.Source) {
return creator.WorkInput{}, accessContext{}, false
}
originalURL := firstString(object, "original_url", "note_url", "url")
if !validOriginalURL(originalURL, id) {
originalURL = noteURL(id, access)
}
cover := coverURL(object)
status := "pending_verification"
if published != nil {
status = "verified"
}
return creator.WorkInput{
Platform: creator.PlatformXiaohongshu,
WorkKey: id,
AuthorName: authorName,
Title: title,
Body: body,
PublishedAt: published,
PublishedAtStatus: status,
OriginalURL: originalURL,
CoverURL: cover,
Likes: likes,
CommentsCount: comments,
Shares: shares,
}, access, true
}
func parseCommentsPage(body []byte, workKey string) ([]creator.CommentInput, string, bool, bool) {
if len(body) == 0 || len(body) > 4<<20 || !keyPattern.MatchString(workKey) {
return nil, "", false, false
}
var envelope struct {
Success *bool `json:"success"`
Data *struct {
Cursor json.RawMessage `json:"cursor"`
HasMore *bool `json:"has_more"`
Comments []json.RawMessage `json:"comments"`
} `json:"data"`
}
if json.Unmarshal(body, &envelope) != nil || envelope.Success == nil || !*envelope.Success || envelope.Data == nil || envelope.Data.HasMore == nil || envelope.Data.Comments == nil || len(envelope.Data.Comments) > 100 {
return nil, "", false, false
}
items := make([]creator.CommentInput, 0, len(envelope.Data.Comments))
seen := make(map[string]struct{}, len(envelope.Data.Comments))
for _, raw := range envelope.Data.Comments {
object, ok := objectValue(raw)
if !ok {
return nil, "", false, false
}
id := firstString(object, "id", "comment_id", "commentId")
if !keyPattern.MatchString(id) {
return nil, "", false, false
}
if parent := firstString(object, "parent_comment_id", "parent_id", "reply_id"); parent != "" {
return nil, "", false, false
}
if typ := firstString(object, "comment_type", "type"); typ == "reply" || typ == "sub" {
return nil, "", false, false
}
if _, exists := seen[id]; exists {
return nil, "", false, false
}
seen[id] = struct{}{}
user := firstObject(object, "user_info", "user", "author")
published, publishedOK := optionalTimestamp(object, "create_time", "time", "created_at")
if !publishedOK {
return nil, "", false, false
}
content := firstString(object, "content", "text", "comment")
if content == "" || utf8.RuneCountInString(content) > 20000 {
return nil, "", false, false
}
items = append(items, creator.CommentInput{
Platform: creator.PlatformXiaohongshu,
CommentKey: id,
WorkID: workKey,
AuthorUID: firstString(user, "user_id", "uid", "id"),
AuthorName: firstString(user, "nickname", "name"),
Content: content,
PublishedAt: published,
CommentType: "top_level",
})
}
nextCursor, ok := cursorValue(envelope.Data.Cursor)
if !ok || *envelope.Data.HasMore && nextCursor == "" {
return nil, "", false, false
}
return items, nextCursor, *envelope.Data.HasMore, true
}
func responseError(response Response, resource string) error {
if strings.TrimSpace(response.Challenge) != "" {
return fmt.Errorf("%w: xiaohongshu %s challenge %s", creator.ErrUnavailable, resource, response.Challenge)
}
if response.Status >= 200 && response.Status < 300 {
return nil
}
switch response.Status {
case 401, 403, 406, 461:
return fmt.Errorf("%w: xiaohongshu %s authentication/session rejected with HTTP %d", creator.ErrConflict, resource, response.Status)
case 429:
return fmt.Errorf("%w: xiaohongshu %s rate limited", creator.ErrUnavailable, resource)
default:
return fmt.Errorf("xiaohongshu %s returned HTTP %d", resource, response.Status)
}
}
func validCursor(cursor string) bool {
return cursor == "" || len(cursor) <= 512 && !strings.ContainsAny(cursor, "\r\n")
}
func objectValue(raw json.RawMessage) (map[string]json.RawMessage, bool) {
var object map[string]json.RawMessage
if len(raw) == 0 || json.Unmarshal(raw, &object) != nil || object == nil {
return nil, false
}
return object, true
}
func firstObject(object map[string]json.RawMessage, names ...string) map[string]json.RawMessage {
for _, name := range names {
if value, ok := object[name]; ok {
if nested, ok := objectValue(value); ok {
return nested
}
}
}
return nil
}
func firstString(object map[string]json.RawMessage, names ...string) string {
for _, name := range names {
value, ok := object[name]
if !ok || string(value) == "null" {
continue
}
var text string
if json.Unmarshal(value, &text) == nil {
return strings.TrimSpace(text)
}
var number json.Number
decoder := json.NewDecoder(bytes.NewReader(value))
decoder.UseNumber()
if decoder.Decode(&number) == nil {
return number.String()
}
}
return ""
}
func optionalInt(object map[string]json.RawMessage, names ...string) (*int64, bool) {
for _, name := range names {
value, ok := object[name]
if !ok {
continue
}
if string(value) == "null" {
return nil, true
}
var number json.Number
decoder := json.NewDecoder(bytes.NewReader(value))
decoder.UseNumber()
if decoder.Decode(&number) != nil {
var text string
if json.Unmarshal(value, &text) != nil {
return nil, false
}
number = json.Number(text)
}
parsed, err := strconv.ParseInt(number.String(), 10, 64)
if err != nil || parsed < 0 {
return nil, false
}
return &parsed, true
}
return nil, true
}
func optionalTimestamp(object map[string]json.RawMessage, names ...string) (*time.Time, bool) {
for _, name := range names {
value, ok := object[name]
if !ok {
continue
}
if string(value) == "null" {
return nil, true
}
var number json.Number
decoder := json.NewDecoder(bytes.NewReader(value))
decoder.UseNumber()
if decoder.Decode(&number) != nil {
var text string
if json.Unmarshal(value, &text) != nil {
return nil, false
}
number = json.Number(text)
}
parsed, err := strconv.ParseInt(number.String(), 10, 64)
if err != nil || parsed <= 0 {
return nil, true
}
if parsed > 1_000_000_000_000 {
parsed /= 1000
}
if parsed <= 0 || parsed > 4_102_444_800 {
return nil, false
}
when := time.Unix(parsed, 0).UTC()
return &when, true
}
return nil, true
}
func cursorValue(raw json.RawMessage) (string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return "", true
}
var text string
if json.Unmarshal(raw, &text) == nil {
return strings.TrimSpace(text), validCursor(strings.TrimSpace(text))
}
var number json.Number
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if decoder.Decode(&number) != nil {
return "", false
}
return number.String(), validCursor(number.String())
}
func coverURL(object map[string]json.RawMessage) string {
images, ok := object["image_list"]
if ok {
var values []json.RawMessage
if json.Unmarshal(images, &values) == nil {
for _, value := range values {
if image, ok := objectValue(value); ok {
if cover := firstString(image, "url_default", "url_pre", "url_original", "url"); cover != "" {
return cover
}
}
}
}
}
if cover := firstObject(object, "cover"); cover != nil {
return firstString(cover, "url_default", "url_pre", "url_original", "url")
}
return firstString(object, "cover_url", "cover")
}
func validOriginalURL(raw, id string) bool {
if raw == "" {
return false
}
parsed, err := url.Parse(raw)
return err == nil && parsed.Scheme == "https" && (parsed.Host == "www.xiaohongshu.com" || parsed.Host == "xhslink.com") && strings.Contains(parsed.Path, id)
}
func noteURL(id string, access accessContext) string {
query := url.Values{}
if access.Token != "" {
query.Set("xsec_token", access.Token)
}
if access.Source != "" {
query.Set("xsec_source", access.Source)
}
result := "https://www.xiaohongshu.com/explore/" + url.PathEscape(id)
if encoded := query.Encode(); encoded != "" {
result += "?" + encoded
}
return result
}
+82
View File
@@ -0,0 +1,82 @@
package xiaohongshu
import (
"context"
"errors"
"net/url"
"testing"
"git.ipao.vip/rogee/creator-hub/internal/creator"
)
type fakeBrowser struct {
getURL string
postURL string
getBody []byte
postBody []byte
getResp Response
postResp Response
}
func (f *fakeBrowser) Get(_ context.Context, target string) (Response, error) {
f.getURL = target
return f.getResp, nil
}
func (f *fakeBrowser) Post(_ context.Context, target string, body []byte) (Response, error) {
f.postURL, f.postBody = target, body
return f.postResp, nil
}
func TestIdentityRequiresMatchingUser(t *testing.T) {
browser := &fakeBrowser{getResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"user_id":"u-1","nickname":"作者"}}`)}}
collector := &Collector{Browser: browser}
identity, err := collector.Identity(context.Background(), "u-1")
if err != nil || identity.UserID != "u-1" {
t.Fatalf("identity = %#v, err = %v", identity, err)
}
if _, err := collector.Identity(context.Background(), "u-2"); !errors.Is(err, creator.ErrConflict) {
t.Fatalf("expected identity conflict, got %v", err)
}
}
func TestListWorksCapturesContextForComments(t *testing.T) {
browser := &fakeBrowser{getResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"cursor":"next","has_more":true,"notes":[{"note_id":"n-1","title":"标题","desc":"正文","time":1710000000,"user":{"user_id":"u-1","nickname":"作者"},"interact_info":{"liked_count":"2","comment_count":3,"shared_count":4},"xsec_token":"token"}]}}`)}}
collector := &Collector{Browser: browser, AccountKey: "u-1", SourceType: creator.SourceOwned, SourceID: "source-1"}
page, err := collector.ListWorks(context.Background(), "ignored", "")
if err != nil || len(page.Items) != 1 || page.NextCursor != "next" || !page.HasMore {
t.Fatalf("page = %#v, err = %v", page, err)
}
parsed, err := url.Parse(browser.getURL)
if err != nil || parsed.Query().Get("user_id") != "u-1" {
t.Fatalf("request URL = %s", browser.getURL)
}
browser.getResp = Response{Status: 200, Body: []byte(`{"success":true,"data":{"cursor":"","has_more":false,"comments":[{"id":"c-1","content":"评论","create_time":1710000000,"user_info":{"user_id":"u-2","nickname":"读者"}}]}}`)}
comments, err := collector.ListTopLevelComments(context.Background(), "n-1", "")
if err != nil || len(comments.Items) != 1 || comments.Items[0].CommentType != "top_level" {
t.Fatalf("comments = %#v, err = %v", comments, err)
}
commentURL, err := url.Parse(browser.getURL)
if err != nil || commentURL.Query().Get("xsec_token") != "token" {
t.Fatalf("comment request URL = %s", browser.getURL)
}
}
func TestSearchUsesBoundedPostAndRejectsMalformedPage(t *testing.T) {
browser := &fakeBrowser{postResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"cursor":"","has_more":false,"notes":[]}}`)}}
collector := &Collector{Browser: browser}
page, err := collector.SearchNotes(context.Background(), "关键词", 1)
if err != nil || page.HasMore || len(page.Items) != 0 || browser.postURL != SearchOrigin+SearchNotesPath {
t.Fatalf("page = %#v, URL = %s, err = %v", page, browser.postURL, err)
}
browser.postResp = Response{Status: 200, Body: []byte(`{"success":true,"data":{"has_more":true,"notes":[]}}`)}
if _, err := collector.SearchNotes(context.Background(), "关键词", 1); !errors.Is(err, creator.ErrInvalid) {
t.Fatalf("expected malformed response error, got %v", err)
}
}
func TestSetWorkContextRejectsWrongHost(t *testing.T) {
collector := &Collector{}
if err := collector.SetWorkContext("n-1", "https://evil.example/explore/n-1"); !errors.Is(err, creator.ErrInvalid) {
t.Fatalf("expected invalid URL, got %v", err)
}
}
+101
View File
@@ -0,0 +1,101 @@
package xiaohongshu
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"git.ipao.vip/rogee/creator-hub/internal/creator"
)
// GetNoteDetail reads one note through the platform feed endpoint and keeps
// the original link so xsec context survives a later comments resume.
func (c *Collector) GetNoteDetail(ctx context.Context, originalURL string) (creator.WorkInput, error) {
if c == nil || c.Browser == nil {
return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu detail request", creator.ErrInvalid)
}
parsed, err := url.Parse(originalURL)
if err != nil || parsed.Scheme != "https" || parsed.Host != "www.xiaohongshu.com" {
return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu note URL", creator.ErrInvalid)
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) != 2 || parts[0] != "explore" || !keyPattern.MatchString(parts[1]) {
return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu note URL", creator.ErrInvalid)
}
access := accessContext{Token: parsed.Query().Get("xsec_token"), Source: parsed.Query().Get("xsec_source")}
if access.Source == "" {
access.Source = "pc_search"
}
if len(access.Token) > 2048 || !keyPattern.MatchString(access.Source) {
return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu note URL context", creator.ErrInvalid)
}
body, err := json.Marshal(map[string]any{
"source_note_id": parts[1],
"image_formats": []string{"jpg", "webp", "avif"},
"extra": map[string]string{"need_body_topic": "1"},
"xsec_source": access.Source,
"xsec_token": access.Token,
})
if err != nil {
return creator.WorkInput{}, fmt.Errorf("encode xiaohongshu detail request: %w", err)
}
response, err := c.Browser.Post(ctx, APIOrigin+FeedPath, body)
if err != nil {
return creator.WorkInput{}, err
}
if err := responseError(response, "detail"); err != nil {
return creator.WorkInput{}, err
}
item, parsedAccess, ok := parseDetail(response.Body, access)
if !ok {
return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu detail response", creator.ErrInvalid)
}
if item.WorkKey != parts[1] {
return creator.WorkInput{}, fmt.Errorf("%w: xiaohongshu detail returned another note", creator.ErrConflict)
}
item.OriginalURL = originalURL
item.SourceType = c.SourceType
item.SourceID = c.SourceID
if item.SourceType == "" {
item.SourceType = creator.SourceCompetitor
}
if item.SourceID == "" {
item.SourceID = c.AccountKey
}
c.contexts.Store(item.WorkKey, parsedAccess)
return item, nil
}
func parseDetail(body []byte, fallback accessContext) (creator.WorkInput, accessContext, bool) {
if len(body) == 0 || len(body) > 4<<20 {
return creator.WorkInput{}, accessContext{}, false
}
var envelope struct {
Success *bool `json:"success"`
Data json.RawMessage `json:"data"`
}
if json.Unmarshal(body, &envelope) != nil || envelope.Success == nil || !*envelope.Success || len(envelope.Data) == 0 {
return creator.WorkInput{}, accessContext{}, false
}
data, ok := objectValue(envelope.Data)
if !ok {
return creator.WorkInput{}, accessContext{}, false
}
if items, ok := arrayValue(data["items"]); ok && len(items) > 0 {
return parseWork(items[0], fallback)
}
if note := data["note"]; len(note) > 0 {
return parseWork(note, fallback)
}
return parseWork(envelope.Data, fallback)
}
func arrayValue(raw json.RawMessage) ([]json.RawMessage, bool) {
var values []json.RawMessage
if len(raw) == 0 || json.Unmarshal(raw, &values) != nil || values == nil {
return nil, false
}
return values, true
}
+33
View File
@@ -0,0 +1,33 @@
package xiaohongshu
import (
"context"
"errors"
"testing"
"git.ipao.vip/rogee/creator-hub/internal/creator"
)
func TestGetNoteDetailPreservesShareContext(t *testing.T) {
browser := &fakeBrowser{postResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"items":[{"note_id":"n-1","title":"详情","desc":"正文","time":1710000000,"user":{"user_id":"u-1","nickname":"作者"},"interact_info":{"liked_count":1,"comment_count":2,"shared_count":3}}]}}`)}}
collector := &Collector{Browser: browser, SourceType: creator.SourceCompetitor, SourceID: "source-1"}
item, err := collector.GetNoteDetail(context.Background(), "https://www.xiaohongshu.com/explore/n-1?xsec_token=tok-1&xsec_source=pc_search")
if err != nil || item.WorkKey != "n-1" || item.OriginalURL == "" {
t.Fatalf("item = %#v, err = %v", item, err)
}
if err := collector.SetWorkContext("n-1", item.OriginalURL); err != nil {
t.Fatalf("set work context: %v", err)
}
if browser.postURL != APIOrigin+FeedPath {
t.Fatalf("post URL = %s", browser.postURL)
}
}
func TestGetNoteDetailRejectsWrongResponseID(t *testing.T) {
browser := &fakeBrowser{postResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"items":[{"note_id":"n-2","title":"详情","time":1710000000,"interact_info":{}}]}}`)}}
collector := &Collector{Browser: browser}
_, err := collector.GetNoteDetail(context.Background(), "https://www.xiaohongshu.com/explore/n-1")
if !errors.Is(err, creator.ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
}