package plugins import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "html" "image" "image/color" "image/draw" "image/jpeg" _ "image/png" "io" "log" "mime/multipart" "net/http" "net/url" "path" "regexp" "strings" "time" "github.com/go-resty/resty/v2" xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" "wechat-robot-client/dto" "wechat-robot-client/interface/plugin" "wechat-robot-client/pkg/robot" "wechat-robot-client/utils" "wechat-robot-client/vars" ) type VideoParseResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data VideoParseData `json:"data"` } type VideoParseData struct { Author string `json:"author"` Avatar string `json:"avatar"` Title string `json:"title"` Desc string `json:"desc"` Digg int32 `json:"digg"` Comment int32 `json:"comment"` Play int32 `json:"play"` CreateTime int64 `json:"create_time"` Cover string `json:"cover"` URL string `json:"url"` Images []string `json:"images"` MusicURL string `json:"music_url"` } type DouyinRouterData struct { LoaderData map[string]DouyinLoaderPageData `json:"loaderData"` } type DouyinLoaderPageData struct { VideoInfoRes DouyinVideoInfoRes `json:"videoInfoRes"` } type DouyinVideoInfoRes struct { ItemList []DouyinAwemeItem `json:"item_list"` } type DouyinAwemeItem struct { Desc string `json:"desc"` Author DouyinAuthor `json:"author"` Music DouyinMusic `json:"music"` Video DouyinVideo `json:"video"` Images []DouyinImageInfo `json:"images"` ImageInfos []DouyinImageInfo `json:"image_infos"` ImgBitrate []DouyinImageGear `json:"img_bitrate"` } type DouyinAuthor struct { Nickname string `json:"nickname"` Signature string `json:"signature"` AvatarThumb DouyinURLResource `json:"avatar_thumb"` AvatarMedium DouyinURLResource `json:"avatar_medium"` } type DouyinMusic struct { Mid string `json:"mid"` Title string `json:"title"` Author string `json:"author"` PlayURL DouyinURLResource `json:"play_url"` CoverHD DouyinURLResource `json:"cover_hd"` CoverLarge DouyinURLResource `json:"cover_large"` CoverMedium DouyinURLResource `json:"cover_medium"` CoverThumb DouyinURLResource `json:"cover_thumb"` } type DouyinVideo struct { Duration *int64 `json:"duration"` PlayAddr DouyinURLResource `json:"play_addr"` Cover DouyinURLResource `json:"cover"` } type DouyinImageInfo struct { URI string `json:"uri"` URLList []string `json:"url_list"` DownloadURLList []string `json:"download_url_list"` } type DouyinImageGear struct { Name string `json:"name"` Images []DouyinImageInfo `json:"images"` } type DouyinURLResource struct { URI string `json:"uri"` URLList []string `json:"url_list"` } const douyinUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1" var ( douyinRouterDataRegexp = regexp.MustCompile(`(?s)window\._ROUTER_DATA\s*=\s*({.*?})\s*`) ) type DouyinVideoParsePlugin struct{} func NewDouyinVideoParsePlugin() plugin.MessageHandler { return &DouyinVideoParsePlugin{} } func (p *DouyinVideoParsePlugin) GetName() string { return "DouyinVideoParse" } func (p *DouyinVideoParsePlugin) GetLabels() []string { return []string{"text", "douyin"} } func (p *DouyinVideoParsePlugin) PreAction(ctx *plugin.MessageContext) bool { if ctx.Message.IsChatRoom { next := NewChatRoomCommonPlugin().PreAction(ctx) if !next { return false } if !ctx.Settings.IsShortVideoParsingEnabled() { return false } } return true } func (p *DouyinVideoParsePlugin) PostAction(ctx *plugin.MessageContext) { } func (p *DouyinVideoParsePlugin) Match(ctx *plugin.MessageContext) bool { return strings.Contains(ctx.Message.Content, "https://v.douyin.com") } func (p *DouyinVideoParsePlugin) Run(ctx *plugin.MessageContext) { if !p.PreAction(ctx) { return } re := regexp.MustCompile(`https://[^\s]+`) matches := re.FindAllString(ctx.Message.Content, -1) if len(matches) == 0 { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "未找到抖音链接") return } douyinURL := matches[0] respData, err := parseDouyinVideo(douyinURL) if err != nil { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("解析失败: %v", err)) return } if respData.Data.URL != "" { shareLink := robot.ShareLinkMessage{ Title: fmt.Sprintf("抖音视频解析成功 - %s", respData.Data.Author), Des: respData.Data.Title, Url: respData.Data.URL, ThumbUrl: robot.CDATAString("https://mmbiz.qpic.cn/mmbiz_png/NbW0ZIUM8lVHoUbjXw2YbYXbNJDtUH7Sbkibm9Qwo9FhAiaEFG4jY3Q2MEleRpiaWDyDv8BZUfR85AW3kG4ib6DyAw/640?wx_fmt=png"), } if respData.Data.Desc != "" { shareLink.Des = respData.Data.Desc } _ = ctx.MessageService.ShareLink(ctx.Message.FromWxID, shareLink) err = ctx.MessageService.SendVideoMessageByRemoteURL(ctx.Message.FromWxID, respData.Data.URL) if err != nil { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送抖音视频失败: %v", err.Error())) } return } if len(respData.Data.Images) > 0 { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("抖音图片解析成功\n作者: %s\n标题: %s\n\n%d张图片正在发送中...", respData.Data.Author, respData.Data.Title, len(respData.Data.Images))) if respData.Data.MusicURL != "" { go func(musicURL, title, author string) { var err error if isAudioURL(musicURL) { err = sendMusicMessageByURL(ctx, musicURL, author) } else { err = sendFileByRemoteURL(ctx, musicURL) } if err != nil { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送抖音音频失败: %v", err)) } }(respData.Data.MusicURL, respData.Data.Title, respData.Data.Author) } imageURLs := respData.Data.Images batchSize := 20 for i := 0; i < len(imageURLs); i += batchSize { end := i + batchSize end = min(end, len(imageURLs)) mergedImage, err := mergeImagesVertical(ctx, imageURLs[i:end]) if err != nil { if isImageTooLargeError(err) { p.sendImagesInSmallerBatches(ctx, imageURLs[i:end], 10) continue } ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("拼接失败(批次 %d-%d): %v", i+1, end, err)) continue } if len(mergedImage) == 0 { continue } err = sendMergedImage(ctx, mergedImage) if err != nil { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送图片失败: %v", err)) } } return } ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, "解析失败,可能是链接已失效或格式不正确") } func parseDouyinVideo(rawURL string) (VideoParseResponse, error) { resolvedURL, err := resolveDouyinRedirect(rawURL) if err != nil { return VideoParseResponse{}, err } htmlContent, err := fetchDouyinPageHTML(resolvedURL) if err != nil { return VideoParseResponse{}, err } data, err := parseDouyinPageHTML(htmlContent) if err != nil { return VideoParseResponse{}, err } return VideoParseResponse{Code: http.StatusOK, Data: data}, nil } func resolveDouyinRedirect(rawURL string) (string, error) { client := &http.Client{ Timeout: 15 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) if err != nil { return "", fmt.Errorf("创建抖音短链请求失败: %w", err) } req.Header.Set("User-Agent", douyinUserAgent) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("解析抖音短链失败: %w", err) } defer resp.Body.Close() if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest { location, err := resp.Location() if err != nil { return rawURL, nil } return location.String(), nil } return resp.Request.URL.String(), nil } func fetchDouyinPageHTML(pageURL string) (string, error) { client := &http.Client{Timeout: 15 * time.Second} req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, pageURL, nil) if err != nil { return "", fmt.Errorf("创建抖音页面请求失败: %w", err) } req.Header.Set("User-Agent", douyinUserAgent) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("获取抖音页面失败: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("获取抖音页面失败,状态码: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("读取抖音页面失败: %w", err) } if len(body) == 0 { return "", fmt.Errorf("抖音页面内容为空") } return string(body), nil } func parseDouyinPageHTML(htmlContent string) (VideoParseData, error) { if item, ok := extractDouyinAwemeItem(htmlContent); ok { if note, ok := parseDouyinNoteItem(item); ok { return note, nil } if video, ok := parseDouyinVideoItem(item); ok { return video, nil } } return VideoParseData{}, fmt.Errorf("阿拉蕾,解析出错了~") } func extractDouyinAwemeItem(htmlContent string) (DouyinAwemeItem, bool) { match := douyinRouterDataRegexp.FindStringSubmatch(htmlContent) if len(match) < 2 { return DouyinAwemeItem{}, false } var routerData DouyinRouterData if err := json.Unmarshal([]byte(match[1]), &routerData); err != nil { log.Printf("解析抖音 _ROUTER_DATA 失败: %v\n", err) return DouyinAwemeItem{}, false } for _, pageData := range routerData.LoaderData { if len(pageData.VideoInfoRes.ItemList) > 0 { return pageData.VideoInfoRes.ItemList[0], true } } return DouyinAwemeItem{}, false } func parseDouyinNoteItem(item DouyinAwemeItem) (VideoParseData, bool) { imageURLGroups := pickDouyinImageURLGroups(item) if len(imageURLGroups) == 0 { return VideoParseData{}, false } imageURLs := make([]string, 0, len(imageURLGroups)) for _, group := range imageURLGroups { imageURLs = append(imageURLs, group[0]) } desc := cleanDouyinText(item.Desc) return VideoParseData{ Author: cleanDouyinText(item.Author.Nickname), Avatar: pickDouyinAvatarURL(item.Author), Title: desc, Desc: desc, Images: imageURLs, MusicURL: pickDouyinNoteMusicURL(item), }, true } func pickDouyinImageURLGroups(item DouyinAwemeItem) [][]string { imageList := item.Images if len(imageList) == 0 { imageList = item.ImageInfos } imageURLGroups := make([][]string, 0, len(imageList)) seenGroups := make(map[string]bool) for _, imageInfo := range imageList { candidates := make([]string, 0) seenURLs := make(map[string]bool) for _, imageURL := range imageInfo.URLList { if !strings.HasPrefix(imageURL, "http") { continue } decodedURL := html.UnescapeString(imageURL) if seenURLs[decodedURL] { continue } candidates = append(candidates, decodedURL) seenURLs[decodedURL] = true } groupKey := strings.Join(candidates, "\x00") if len(candidates) > 0 && !seenGroups[groupKey] { imageURLGroups = append(imageURLGroups, candidates) seenGroups[groupKey] = true } } return imageURLGroups } func parseDouyinVideoItem(item DouyinAwemeItem) (VideoParseData, bool) { if item.Video.Duration != nil && *item.Video.Duration == 0 { return VideoParseData{}, false } videoURL := pickDouyinVideoURL(item.Video.PlayAddr.URLList) if videoURL == "" { return VideoParseData{}, false } desc := cleanDouyinText(item.Desc) return VideoParseData{ Author: cleanDouyinText(item.Author.Nickname), Avatar: pickDouyinAvatarURL(item.Author), Title: desc, Desc: desc, Cover: pickPreferredDouyinURL(item.Video.Cover.URLList), URL: videoURL, MusicURL: pickPreferredDouyinURL(item.Music.PlayURL.URLList), }, true } func pickDouyinAvatarURL(author DouyinAuthor) string { if avatarURL := pickPreferredDouyinURL(author.AvatarMedium.URLList); avatarURL != "" { return avatarURL } return pickPreferredDouyinURL(author.AvatarThumb.URLList) } func pickDouyinNoteMusicURL(item DouyinAwemeItem) string { if musicURL := pickPreferredDouyinURL(item.Music.PlayURL.URLList); musicURL != "" { return musicURL } if strings.HasPrefix(item.Video.PlayAddr.URI, "http") { return decodeDouyinEscapedValue(item.Video.PlayAddr.URI) } return pickPreferredDouyinURL(item.Video.PlayAddr.URLList) } func pickDouyinVideoURL(urls []string) string { decodedURLs := make([]string, 0, len(urls)) for _, rawURL := range urls { if rawURL == "" { continue } decodedURL := strings.ReplaceAll(decodeDouyinEscapedValue(rawURL), "playwm", "play") decodedURLs = append(decodedURLs, decodedURL) } for _, decodedURL := range decodedURLs { if strings.Contains(decodedURL, "aweme.snssdk.com") { return decodedURL } } if len(decodedURLs) > 0 { return decodedURLs[0] } return "" } func pickPreferredDouyinURL(urls []string) string { firstURL := "" for _, rawURL := range urls { if rawURL == "" { continue } decodedURL := decodeDouyinEscapedValue(rawURL) if decodedURL == "" { continue } if strings.HasPrefix(decodedURL, "https://p26") { return decodedURL } if firstURL == "" { firstURL = decodedURL } } return firstURL } func matchDouyinJSONString(text string, key string) string { pattern := regexp.MustCompile(fmt.Sprintf(`"%s":\s*"([^"]*)"`, regexp.QuoteMeta(key))) match := pattern.FindStringSubmatch(text) if len(match) < 2 { return "" } return cleanDouyinText(decodeDouyinEscapedValue(match[1])) } func decodeDouyinEscapedValue(value string) string { decodedValue := html.UnescapeString(value) if strings.Contains(decodedValue, `\`) { var unquotedValue string if err := json.Unmarshal([]byte(`"`+strings.ReplaceAll(decodedValue, `"`, `\"`)+`"`), &unquotedValue); err == nil { decodedValue = unquotedValue } } return html.UnescapeString(decodedValue) } func cleanDouyinText(value string) string { return strings.TrimSpace(html.UnescapeString(value)) } func nestedString(root map[string]any, keys ...string) string { current := any(root) for _, key := range keys { currentMap, ok := current.(map[string]any) if !ok { return "" } current = currentMap[key] } return stringFromAny(current) } func nestedStringList(root map[string]any, keys ...string) []string { current := any(root) for _, key := range keys { currentMap, ok := current.(map[string]any) if !ok { return nil } current = currentMap[key] } return stringListFromAny(current) } func stringFromAny(value any) string { if value == nil { return "" } if str, ok := value.(string); ok { return str } return fmt.Sprint(value) } func listFromAny(value any) []any { if list, ok := value.([]any); ok { return list } return nil } func stringListFromAny(value any) []string { list, ok := value.([]any) if !ok { return nil } stringsList := make([]string, 0, len(list)) for _, item := range list { if str, ok := item.(string); ok { stringsList = append(stringsList, str) } } return stringsList } func numberFromAny(value any) (float64, bool) { switch number := value.(type) { case float64: return number, true case int: return float64(number), true case int64: return float64(number), true default: return 0, false } } func (p *DouyinVideoParsePlugin) sendImagesInSmallerBatches(ctx *plugin.MessageContext, imageURLs []string, batchSize int) { if batchSize <= 0 { return } for i := 0; i < len(imageURLs); i += batchSize { end := i + batchSize end = min(end, len(imageURLs)) mergedImage, err := mergeImagesVertical(ctx, imageURLs[i:end]) if err != nil { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("拼接失败(降级批次 %d-%d): %v", i+1, end, err)) continue } if len(mergedImage) == 0 { continue } err = sendMergedImage(ctx, mergedImage) if err != nil { ctx.MessageService.SendTextMessage(ctx.Message.FromWxID, fmt.Sprintf("发送图片失败: %v", err)) } } } func mergeImagesVertical(ctx *plugin.MessageContext, imageURLs []string) ([]byte, error) { if len(imageURLs) == 0 { return nil, fmt.Errorf("图片地址为空") } client := resty.New() images := make([]image.Image, 0, len(imageURLs)) maxWidth := 0 for _, imageURL := range imageURLs { resp, err := client.R(). SetHeader("User-Agent", douyinUserAgent). SetHeader("Referer", "https://www.douyin.com/"). SetDoNotParseResponse(true). Get(imageURL) if err != nil { return nil, fmt.Errorf("下载图片失败: %w", err) } if resp.StatusCode() != http.StatusOK { resp.RawBody().Close() return nil, fmt.Errorf("下载图片失败,HTTP状态码: %d", resp.StatusCode()) } bodyData := new(bytes.Buffer) _, err = bodyData.ReadFrom(resp.RawBody()) resp.RawBody().Close() if err != nil { return nil, fmt.Errorf("读取响应体失败: %w", err) } if utils.IsVideo(bodyData.Bytes()) { log.Printf("%s 解析到视频,跳过合并,直接发送视频消息\n", imageURL) go func(toWxID, _imageURL string) { err2 := ctx.MessageService.SendVideoMessageByRemoteURL(toWxID, _imageURL) if err2 != nil { ctx.MessageService.SendTextMessage(toWxID, fmt.Sprintf("发送抖音视频失败: %v", err2.Error())) } }(ctx.Message.FromWxID, imageURL) continue } img, _, err := image.Decode(bytes.NewReader(bodyData.Bytes())) if err != nil { return nil, fmt.Errorf("解析图片失败: %w", err) } bounds := img.Bounds() width := bounds.Dx() if width > maxWidth { maxWidth = width } images = append(images, img) } // 有可能全是视频 if maxWidth == 0 || len(images) == 0 { return nil, nil } totalHeight := 0 for _, img := range images { width := img.Bounds().Dx() height := img.Bounds().Dy() // 等比缩放计算高度 newHeight := int(float64(height) * float64(maxWidth) / float64(width)) totalHeight += newHeight } if maxWidth > jpegMaxDimension || totalHeight > jpegMaxDimension { return nil, fmt.Errorf("image is too large to encode") } canvas := image.NewRGBA(image.Rect(0, 0, maxWidth, totalHeight)) draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) currentY := 0 for _, img := range images { width := img.Bounds().Dx() height := img.Bounds().Dy() newHeight := int(float64(height) * float64(maxWidth) / float64(width)) dstRect := image.Rect(0, currentY, maxWidth, currentY+newHeight) // 使用高质量缩放 xdraw.CatmullRom.Scale(canvas, dstRect, img, img.Bounds(), xdraw.Over, nil) currentY += newHeight } var buf bytes.Buffer if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: 80}); err != nil { return nil, fmt.Errorf("图片编码失败: %w", err) } return buf.Bytes(), nil } const jpegMaxDimension = 65535 var audioExtensions = map[string]bool{ ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".flac": true, ".wav": true, ".wma": true, ".amr": true, } func isAudioURL(rawURL string) bool { parsed, err := url.Parse(rawURL) if err != nil { return false } ext := strings.ToLower(path.Ext(parsed.Path)) return audioExtensions[ext] } func sendMusicMessageByURL(ctx *plugin.MessageContext, musicURL, author string) error { const ( appID = "wx8dd6ecd81906fd84" coverURL = "https://uranus-houhou.oss-cn-beijing.aliyuncs.com/douyin.png" ) songInfo := robot.SongInfo{} songInfo.FromUsername = vars.RobotRuntime.WxID songInfo.AppID = appID songInfo.Title = "抖音解析背景音乐" songInfo.Singer = author songInfo.Url = musicURL songInfo.MusicUrl = musicURL songInfo.CoverUrl = coverURL _, err := vars.RobotRuntime.SendMusicMessage(ctx.Message.FromWxID, songInfo) return err } func isImageTooLargeError(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "image is too large to encode") } func sendMergedImage(ctx *plugin.MessageContext, imageData []byte) error { contentLength := int64(len(imageData)) if contentLength == 0 { return nil } fmt.Printf("抖音图片合并后大小: %dMB\n", contentLength/1024/1024) clientImgId := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano()) chunkSize := vars.UploadImageChunkSize totalChunks := int((contentLength + chunkSize - 1) / chunkSize) for chunkIndex := range totalChunks { start := int64(chunkIndex) * chunkSize end := min(start+chunkSize, contentLength) chunkData := imageData[start:end] req := dto.SendImageMessageRequest{ ToWxid: ctx.Message.FromWxID, ClientImgId: clientImgId, FileSize: contentLength, ChunkIndex: int64(chunkIndex), TotalChunks: int64(totalChunks), } chunkReader := bytes.NewReader(chunkData) chunkHeader := &multipart.FileHeader{ Filename: fmt.Sprintf("chunk_%d", chunkIndex), Size: int64(len(chunkData)), } if _, err := ctx.MessageService.SendImageMessageStream(context.Background(), req, chunkReader, chunkHeader); err != nil { return err } } return nil } func sendFileByRemoteURL(ctx *plugin.MessageContext, fileURL string) error { resp, err := resty.New().R().SetDoNotParseResponse(true).Get(fileURL) if err != nil { return fmt.Errorf("下载文件失败: %w", err) } defer resp.RawBody().Close() if resp.StatusCode() != http.StatusOK { return fmt.Errorf("下载文件失败,HTTP状态码: %d", resp.StatusCode()) } fileData, err := io.ReadAll(resp.RawBody()) if err != nil { return fmt.Errorf("读取文件数据失败: %w", err) } if len(fileData) == 0 { return fmt.Errorf("文件数据为空") } parsedURL, err := url.Parse(fileURL) if err != nil { return fmt.Errorf("解析文件URL失败: %w", err) } filename := path.Base(parsedURL.Path) if filename == "" || filename == "/" || filename == "." { filename = "douyin_music.mp3" } fileMD5Bytes := md5.Sum(fileData) fileHash := hex.EncodeToString(fileMD5Bytes[:]) fileSize := int64(len(fileData)) chunkSize := vars.UploadFileChunkSize if chunkSize <= 0 { chunkSize = 200 * 1000 } totalChunks := (fileSize + chunkSize - 1) / chunkSize clientAppDataID := fmt.Sprintf("%v_%v", vars.RobotRuntime.WxID, time.Now().UnixNano()) for chunkIndex := range totalChunks { start := int64(chunkIndex) * chunkSize end := min(start+chunkSize, fileSize) chunkData := fileData[start:end] req := dto.SendFileMessageRequest{ ToWxid: ctx.Message.FromWxID, ClientAppDataId: clientAppDataID, Filename: filename, FileHash: fileHash, FileSize: fileSize, ChunkIndex: int64(chunkIndex), TotalChunks: totalChunks, } chunkReader := bytes.NewReader(chunkData) chunkHeader := &multipart.FileHeader{ Filename: filename, Size: int64(len(chunkData)), } if err = ctx.MessageService.SendFileMessage(context.Background(), req, chunkReader, chunkHeader); err != nil { if strings.Contains(err.Error(), "context canceled") || strings.Contains(err.Error(), "context deadline exceeded") { return fmt.Errorf("发送文件超时") } return err } } return nil }