package uixt import ( "math" "time" "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v4/hrp/internal/code" "github.com/httprunner/httprunner/v4/hrp/internal/json" ) type TargetLabel struct { Text string `json:"text"` Scope Scope `json:"scope"` Regex bool `json:"regex"` Target int `json:"target"` // target count for current label } type FeedConfig struct { TargetCount int `json:"target_count"` TargetLabels []TargetLabel `json:"target_labels"` SleepRandom []interface{} `json:"sleep_random"` } type LiveConfig struct { TargetCount int `json:"target_count"` TargetLabels []TargetLabel `json:"target_labels"` SleepRandom []interface{} `json:"sleep_random"` } type VideoCrawlerConfigs struct { Timeout int `json:"timeout"` // seconds Feed FeedConfig `json:"feed"` Live LiveConfig `json:"live"` } type VideoCrawler struct { driverExt *DriverExt configs *VideoCrawlerConfigs timer *time.Timer // used to help checking if swipe success failedCount int64 FeedCount int `json:"feed_count"` FeedStat map[string]int `json:"feed_stat"` // 分类统计 feed 数量:视频/图文/广告/特效/模板/购物 LiveCount int `json:"live_count"` LiveStat map[string]int `json:"live_stat"` // 分类统计 live 数量:秀场/游戏/电商/多人 } func (vc *VideoCrawler) isFeedTargetAchieved() bool { targetStat := make(map[string]int) for _, targetLabel := range vc.configs.Feed.TargetLabels { targetStat[targetLabel.Text] = targetLabel.Target } log.Info(). Int("current_total", vc.FeedCount). Interface("current_stat", vc.FeedStat). Int("target_total", vc.configs.Feed.TargetCount). Interface("target_stat", targetStat). Msg("display feed crawler progress") // check total feed count if vc.FeedCount < vc.configs.Feed.TargetCount { return false } // check each feed type's count for _, targetLabel := range vc.configs.Feed.TargetLabels { if vc.FeedStat[targetLabel.Text] < targetLabel.Target { return false } } return true } func (vc *VideoCrawler) isLiveTargetAchieved() bool { targetStat := make(map[string]int) for _, targetLabel := range vc.configs.Live.TargetLabels { targetStat[targetLabel.Text] = targetLabel.Target } log.Info(). Int("current_total", vc.LiveCount). Interface("current_stat", vc.LiveStat). Int("target_total", vc.configs.Live.TargetCount). Interface("target_stat", targetStat). Msg("display live crawler progress") // check total live count if vc.LiveCount < vc.configs.Live.TargetCount { return false } // check each live type's count for _, targetLabel := range vc.configs.Live.TargetLabels { if vc.LiveStat[targetLabel.Text] < targetLabel.Target { return false } } return true } func (vc *VideoCrawler) isTargetAchieved() bool { return vc.isFeedTargetAchieved() && vc.isLiveTargetAchieved() } func (vc *VideoCrawler) exitLiveRoom() error { log.Info().Msg("press back to exit live room") return vc.driverExt.Driver.PressBack() } const ( FOUND_FEED_SUCCESS = "found feed success" FOUND_LIVE_SUCCESS = "found live success" ) func (dExt *DriverExt) VideoCrawler(configs *VideoCrawlerConfigs) (err error) { if dExt.plugin == nil { return errors.New("miss plugin for video crawler") } // set default sleep random strategy if not set if configs.Feed.SleepRandom == nil { configs.Feed.SleepRandom = []interface{}{1, 5} } if configs.Live.SleepRandom == nil { configs.Live.SleepRandom = []interface{}{10, 15} } crawler := &VideoCrawler{ driverExt: dExt, configs: configs, failedCount: 0, FeedCount: 0, FeedStat: make(map[string]int), LiveCount: 0, LiveStat: make(map[string]int), } defer func() { dExt.cacheStepData.videoCrawler = crawler }() // loop until target count achieved or timeout // the main loop is feed crawler crawler.timer = time.NewTimer(time.Duration(configs.Timeout) * time.Second) for { select { case <-crawler.timer.C: log.Warn().Msg("timeout in feed crawler") return errors.Wrap(code.TimeoutError, "feed crawler timeout") case <-dExt.interruptSignal: log.Warn().Msg("interrupted in feed crawler") return errors.Wrap(code.InterruptError, "feed crawler interrupted") default: // swipe to next feed video log.Info().Msg("swipe to next feed video") swipeStartTime := time.Now() if err = dExt.SwipeRelative(0.9, 0.8, 0.9, 0.1, WithOffsetRandomRange(-10, 10)); err != nil { log.Error().Err(err).Msg("feed swipe up failed") return err } swipeFinishTime := time.Now() // get app event trackings // retry 10 times if get feed failed, abort if fail 10 consecutive times feedVideo, err := crawler.getCurrentVideo() if err != nil || feedVideo.Type == "" { if crawler.failedCount >= 10 { // failed 10 consecutive times return errors.Wrap(code.TrackingGetError, "get current feed video failed 10 consecutive times") } log.Warn().Msg("get current feed video failed") // check and handle popups if err := crawler.driverExt.ClosePopupsHandler(WithMaxRetryTimes(3)); err != nil { return err } // retry crawler.failedCount++ continue } screenResult := &ScreenResult{ Resolution: dExt.windowSize, Video: feedVideo, // log swipe timelines SwipeStartTime: swipeStartTime.UnixMilli(), SwipeFinishTime: swipeFinishTime.UnixMilli(), } switch feedVideo.Type { case VideoType_PreviewLive: // 直播预览流 if crawler.isLiveTargetAchieved() { // 达标后不再进入直播间 crawler.LiveCount++ dExt.cacheStepData.screenResults[time.Now().String()] = screenResult // 观播时长取随机时长与仿真时长的最小值 sleepTime := math.Min(float64(feedVideo.SimulationPlayDuration), float64(feedVideo.RandomPlayDuration)) feedVideo.PlayDuration = int64(sleepTime) log.Info(). Strs("tags", screenResult.Tags). Interface("video", feedVideo). Msg(FOUND_LIVE_SUCCESS) // simulation watch feed video sleepStrict(swipeFinishTime, feedVideo.PlayDuration) break } else { time.Sleep(1 * time.Second) // live target not achieved, enter live entryPoint := PointF{ X: float64(dExt.windowSize.Width / 2), Y: float64(dExt.windowSize.Height / 2), } log.Info().Msg("tap screen center to enter live room") if err := crawler.driverExt.TapAbsXY(entryPoint.X, entryPoint.Y, WithOffsetRandomRange(-20, 20)); err != nil { log.Error().Err(err).Msg("tap live video failed") continue } } fallthrough case VideoType_Live: // 直播 log.Info(). Strs("tags", screenResult.Tags). Interface("video", feedVideo). Msg(FOUND_LIVE_SUCCESS) // take screenshot and get screen texts by OCR screenResultFromOCR, err := crawler.driverExt.GetScreenResult( WithScreenShotOCR(true), WithScreenShotUpload(true), WithScreenShotLiveType(true), WithScreenShotClosePopups(true), ) if err != nil { log.Error().Err(err).Msg("get screen result failed") time.Sleep(3 * time.Second) continue } if e := crawler.driverExt.tapPopupHandler(screenResultFromOCR.Popup); e != nil { log.Error().Err(e).Msg("auto handle popup failed") continue } // add live type if screenResultFromOCR.imageResult != nil && screenResultFromOCR.imageResult.LiveType != "" && screenResultFromOCR.imageResult.LiveType != "NoLive" { screenResult.Video.LiveType = screenResultFromOCR.imageResult.LiveType } crawler.LiveCount++ // simulation watch feed video sleepStrict(swipeFinishTime, screenResult.Video.PlayDuration) screenResultFromOCR.Video = screenResult.Video screenResultFromOCR.Resolution = screenResult.Resolution screenResultFromOCR.SwipeStartTime = screenResult.SwipeStartTime screenResultFromOCR.SwipeFinishTime = screenResult.SwipeFinishTime screenResultFromOCR.TotalElapsed = time.Since(swipeFinishTime).Milliseconds() if crawler.isLiveTargetAchieved() { log.Info().Interface("live", screenResult.Video). Msg("live count achieved, exit live house") err = crawler.exitLiveRoom() if err != nil { if errors.Is(err, code.TimeoutError) || errors.Is(err, code.InterruptError) { return err } log.Error().Err(err).Msg("run live crawler failed, continue") } } default: // 点播 || 图文 || 广告 || etc. crawler.FeedCount++ dExt.cacheStepData.screenResults[time.Now().String()] = screenResult log.Info(). Strs("tags", screenResult.Tags). Interface("video", feedVideo). Msg(FOUND_FEED_SUCCESS) // simulation watch feed video sleepStrict(swipeFinishTime, screenResult.Video.PlayDuration) } screenResult.TotalElapsed = time.Since(swipeFinishTime).Milliseconds() // check if target count achieved if crawler.isTargetAchieved() { log.Info().Msg("target count achieved, exit crawler") return nil } // reset failed count crawler.failedCount = 0 } } } type VideoType string const ( VideoType_Feed VideoType = "FEED" VideoType_PreviewLive VideoType = "PREVIEW-LIVE" // 直播预览流 VideoType_Live VideoType = "LIVE" VideoType_Image VideoType = "IMAGE" ) type Video struct { Type VideoType `json:"type" required:"true"` // 视频类型, feed/preview-live/live/image DataType string `json:"data_type"` // 数据源对应的事件名称 // Feed 视频基础数据 CacheKey string `json:"cache_key,omitempty"` // cachekey VideoID string `json:"video_id,omitempty"` // 视频 video ID URL string `json:"feed_url,omitempty"` // 实际播放的视频 url UserName string `json:"user_name"` // 视频作者 Duration int64 `json:"duration,omitempty"` // 视频时长(ms) Caption string `json:"caption,omitempty"` // 视频文案 // 视频热度数据 ViewCount int64 `json:"view_count,omitempty"` // feed 观看数 LikeCount int64 `json:"like_count,omitempty"` // feed 点赞数 CommentCount int64 `json:"comment_count,omitempty"` // feed 评论数 CollectCount int64 `json:"collect_count,omitempty"` // feed 收藏数 ForwardCount int64 `json:"forward_count,omitempty"` // feed 转发数 ShareCount int64 `json:"share_count,omitempty"` // feed 分享数 // timelines PublishTimestamp int64 `json:"publish_timestamp,omitempty"` // feed 发布时间戳 PreloadTimestamp int64 `json:"preload_timestamp,omitempty"` // feed 预加载时间戳 // Live 视频基础数据 LiveStreamID string `json:"live_stream_id,omitempty"` // 直播流 ID LiveStreamURL string `json:"live_stream_url,omitempty"` // 直播流地址 LiveType string `json:"live_type,omitempty"` // 直播间类型 // 网络数据 ThroughputKbps int64 `json:"throughput_kbps,omitempty"` // 网速 // 视频热度数据 AudienceCount int64 `json:"audience_count,omitempty"` // 直播间人数 // 图文数据 ImageUrls []string `json:"image_urls,omitempty"` // 图片对应的 url 列表 // 记录仿真决策信息 PlayDuration int64 `json:"play_duration"` // 播放时长(ms),取自 Simulation/Random SimulationPlayProgress float64 `json:"simulation_play_progress"` // 仿真播放比例(完播率) SimulationPlayDuration int64 `json:"simulation_play_duration"` // 仿真播放时长(ms) RandomPlayDuration int64 `json:"random_play_duration"` // 随机播放时长(ms) } func (vc *VideoCrawler) getCurrentVideo() (video *Video, err error) { if !vc.driverExt.plugin.Has("GetCurrentVideo") { return nil, errors.New("plugin missing GetCurrentVideo method") } resp, err := vc.driverExt.plugin.Call("GetCurrentVideo") if err != nil { return nil, errors.Wrap(err, "call plugin GetCurrentVideo failed") } if resp == nil { return nil, errors.New("video not found") } feedBytes, err := json.Marshal(resp) if err != nil { return nil, errors.New("json marshal video info failed") } video = &Video{} err = json.Unmarshal(feedBytes, video) if err != nil { return nil, errors.Wrap(err, "json unmarshal video info failed") } if video.Type == VideoType_Live || video.Type == VideoType_PreviewLive { video.RandomPlayDuration = getSimulationDuration(vc.configs.Live.SleepRandom) } else { video.RandomPlayDuration = getSimulationDuration(vc.configs.Feed.SleepRandom) } // get simulation play duration if video.SimulationPlayDuration != 0 { video.PlayDuration = video.SimulationPlayDuration } else { video.PlayDuration = video.RandomPlayDuration } log.Info(). Str("type", string(video.Type)). Str("dataType", video.DataType). Msg("get current video success") return video, nil }