Просмотр исходного кода

自动替换的高消耗素材的小程序

wangyunpeng 10 часов назад
Родитель
Сommit
42c12f3fbc

+ 21 - 0
api-module/src/main/java/com/tzld/piaoquan/api/component/AdApiService.java

@@ -2,6 +2,7 @@ package com.tzld.piaoquan.api.component;
 
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.tzld.piaoquan.api.model.bo.AdPutCreativeComponentCostData;
 import com.tzld.piaoquan.api.model.bo.AdPutFlowRecordTencent;
 import com.tzld.piaoquan.growth.common.component.HttpPoolClient;
 import lombok.extern.slf4j.Slf4j;
@@ -52,4 +53,24 @@ public class AdApiService {
         }
         return new ArrayList<>();
     }
+
+    /**
+     * 获取投放广告创意花费
+     * @param dt 2025-12-23
+     * @return
+     */
+    public List<AdPutCreativeComponentCostData> getCreativeComponentsCost(String dt) {
+        String url = "https://api.piaoquantv.com/ad" + "/put/tencent/getCreativeComponentsCost?dt=" + dt;
+        try {
+            String post = httpPoolClient.get(url);
+            JSONObject res = JSONObject.parseObject(post);
+            JSONArray data = res.getJSONArray("data");
+            if (Objects.nonNull(data) && !data.isEmpty()) {
+                return data.toJavaList(AdPutCreativeComponentCostData.class);
+            }
+        } catch (Exception e) {
+            log.error("getPutFlowListTencent error", e);
+        }
+        return new ArrayList<>();
+    }
 }

+ 23 - 2
api-module/src/main/java/com/tzld/piaoquan/api/component/AigcApiService.java

@@ -2,6 +2,7 @@ package com.tzld.piaoquan.api.component;
 
 import com.alibaba.fastjson.JSONObject;
 import com.tzld.piaoquan.api.common.exception.CommonException;
+import com.tzld.piaoquan.api.model.bo.GoogleLLMResult;
 import com.tzld.piaoquan.api.model.vo.WxAccountDatastatVO;
 import com.tzld.piaoquan.growth.common.component.HttpPoolClient;
 import com.tzld.piaoquan.growth.common.utils.DateUtil;
@@ -298,7 +299,7 @@ public class AigcApiService {
         if (StringUtils.isEmpty(model) || StringUtils.isEmpty(prompt)) {
             return null;
         }
-        String url = aigcApiHost + "/dev/test/gpt";
+        String url = "http://aigc-api.cybertogether.net/aigc" + "/dev/test/gpt";
         JSONObject params = new JSONObject();
         params.put("model", model);
         params.put("prompt", prompt);
@@ -314,7 +315,27 @@ public class AigcApiService {
             JSONObject res = JSONObject.parseObject(post);
             return res.getString("data");
         } catch (Exception e) {
-            log.error("getDetailByGhId error", e);
+            log.error("gpt error", e);
+        }
+        return null;
+    }
+
+    public GoogleLLMResult gemini(String model, String prompt, List<String> imageList) {
+        if (StringUtils.isEmpty(model) || StringUtils.isEmpty(prompt)) {
+            return null;
+        }
+        String url = "http://aigc-api.cybertogether.net/aigc" + "/infrastructure/gemini/requestWithMedia?model=" + model;
+        JSONObject params = new JSONObject();
+        params.put("prompt", prompt);
+        if (CollectionUtils.isNotEmpty(imageList)) {
+            params.put("mediaList", imageList);
+        }
+
+        try {
+            String post = httpPoolClient.post(url, params.toJSONString());
+            return JSONObject.parseObject(post, GoogleLLMResult.class);
+        } catch (Exception e) {
+            log.error("gemini error", e);
         }
         return null;
     }

+ 11 - 2
api-module/src/main/java/com/tzld/piaoquan/api/controller/AccountDetailController.java

@@ -1,12 +1,13 @@
 package com.tzld.piaoquan.api.controller;
 
-import com.tzld.piaoquan.growth.common.common.enums.GhTypeEnum;
-import com.tzld.piaoquan.growth.common.common.enums.StrategyStatusEnum;
+import com.tzld.piaoquan.api.job.GzhReplyVideoRefreshJob;
 import com.tzld.piaoquan.api.model.vo.GhDetailVo;
 import com.tzld.piaoquan.api.model.vo.GhTypeVo;
 import com.tzld.piaoquan.api.model.vo.StrategyStatusVo;
 import com.tzld.piaoquan.api.service.GhDetailService;
 import com.tzld.piaoquan.growth.common.common.base.CommonResponse;
+import com.tzld.piaoquan.growth.common.common.enums.GhTypeEnum;
+import com.tzld.piaoquan.growth.common.common.enums.StrategyStatusEnum;
 import com.tzld.piaoquan.growth.common.utils.page.Page;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -25,6 +26,8 @@ public class AccountDetailController {
 
     @Autowired
     private GhDetailService ghDetailService;
+    @Autowired
+    private GzhReplyVideoRefreshJob gzhReplyVideoRefreshJob;
 
     @GetMapping("/getList")
     public CommonResponse<Page<GhDetailVo>> getAccountDetailList(@RequestParam(defaultValue = "1") Integer pageNum,
@@ -63,4 +66,10 @@ public class AccountDetailController {
                 .map(strategyStatusEnum -> new StrategyStatusVo(strategyStatusEnum.status, strategyStatusEnum.name))
                 .collect(Collectors.toList()));
     }
+
+    @GetMapping("/job/gzhReplyVideoRefreshJob")
+    public CommonResponse<Void> gzhReplyVideoRefreshJob() {
+        gzhReplyVideoRefreshJob.gzhReplyVideoRefreshJob(null);
+        return CommonResponse.success();
+    }
 }

+ 258 - 0
api-module/src/main/java/com/tzld/piaoquan/api/job/GzhReplyVideoRefreshJob.java

@@ -0,0 +1,258 @@
+package com.tzld.piaoquan.api.job;
+
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.odps.data.Record;
+import com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.tzld.piaoquan.api.component.AdApiService;
+import com.tzld.piaoquan.api.component.AigcApiService;
+import com.tzld.piaoquan.api.component.DeepSeekApiService;
+import com.tzld.piaoquan.api.model.bo.AdPutCreativeComponentCostData;
+import com.tzld.piaoquan.api.model.bo.GoogleLLMResult;
+import com.tzld.piaoquan.api.model.bo.VideoDetail;
+import com.tzld.piaoquan.api.model.dto.AIResult;
+import com.tzld.piaoquan.api.model.vo.GhDetailVo;
+import com.tzld.piaoquan.api.service.GhDetailService;
+import com.tzld.piaoquan.api.util.AliOssFileTool;
+import com.tzld.piaoquan.api.util.CdnUtil;
+import com.tzld.piaoquan.growth.common.model.po.GhDetail;
+import com.tzld.piaoquan.growth.common.utils.DateUtil;
+import com.tzld.piaoquan.growth.common.utils.OdpsUtil;
+import com.tzld.piaoquan.growth.common.utils.TitleSimilarCheckUtil;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+public class GzhReplyVideoRefreshJob {
+
+    @Autowired
+    GhDetailService ghDetailService;
+    @Autowired
+    AdApiService adApiService;
+    @Autowired
+    AigcApiService aigcApiService;
+    @Autowired
+    DeepSeekApiService deepSeekApiService;
+
+    @ApolloJsonValue("${gzh.reply.video.refresh.job.wxIds:[]}")
+    private List<String> wxIds;
+    @Value("${gzh.reply.video.refresh.job.topNum:2}")
+    private Integer topNum;
+
+    private final static ExecutorService pool = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.SECONDS,
+            new LinkedBlockingQueue<>(1000),
+            new ThreadFactoryBuilder().setNameFormat("GzhReplyVideoRefreshJob-%d").build(),
+            new ThreadPoolExecutor.AbortPolicy());
+
+    @XxlJob("gzhReplyVideoRefreshJob")
+    public ReturnT<String> gzhReplyVideoRefreshJob(String param) {
+        String dt =DateUtil.getBeforeDayDateString("yyyy-MM-dd");
+        if (StringUtils.isNotEmpty(param)) {
+            dt = param;
+        }
+        List<AdPutCreativeComponentCostData> list = adApiService.getCreativeComponentsCost(dt);
+        if (CollectionUtils.isEmpty(list)) {
+            return ReturnT.SUCCESS;
+        }
+        // 按公众号分组
+        Map<String, List<AdPutCreativeComponentCostData>> costDataMap = list.stream()
+                .filter(o ->CollectionUtils.isNotEmpty(o.getComponentIds()))
+                .collect(Collectors.groupingBy(AdPutCreativeComponentCostData::getWxId));
+        CountDownLatch cdl = new CountDownLatch(costDataMap.size());
+        // 获取所有投流账号信息
+        String accountMapSql = "SELECT account_id, account_name from loghubods.feishu_wechat_mp_account_base;";
+        List<Record> accountList = OdpsUtil.getOdpsData(accountMapSql);
+        Map<String, String> accountMap = new HashMap<>();
+        if (CollectionUtils.isNotEmpty(accountList)) {
+            for (Record record : accountList) {
+                String accountId = record.getString(0);
+                String accountName = record.getString(1);
+                accountMap.put(accountId, accountName);
+            }
+        }
+        // 获取公众号信息
+        List<String> nameList = new ArrayList<>(accountMap.values());
+        List<GhDetail> ghDetailList = ghDetailService.getGhdetailByNames(nameList);
+        Map<String, GhDetail> ghDetailMap = ghDetailList.stream().collect(Collectors.toMap(GhDetail::getGhName, ghDetail -> ghDetail));
+        for (Map.Entry<String, List<AdPutCreativeComponentCostData>> entry : costDataMap.entrySet()) {
+            pool.submit(() -> {
+                try {
+                    replaceGZHReplyVideo(entry.getKey(), entry.getValue(), ghDetailMap, accountMap);
+                } finally {
+                    cdl.countDown();
+                }
+            });
+        }
+        try {
+            cdl.await();
+        } catch (InterruptedException e) {
+            log.error("GzhReplyVideoRefreshJob error", e);
+        }
+        return ReturnT.SUCCESS;
+    }
+
+    private void replaceGZHReplyVideo(String wxId, List<AdPutCreativeComponentCostData> costDataList,
+                                      Map<String, GhDetail> ghDetailMap,
+                                      Map<String, String> accountMap) {
+        if (!wxIds.contains(wxId)) {
+            return;
+        }
+        String accountName = accountMap.get(wxId);
+        if (StringUtils.isEmpty(accountName)) {
+            return;
+        }
+        GhDetail ghDetail = ghDetailMap.get(accountName);
+        if (ghDetail == null) {
+            return;
+        }
+        Map<String, Long> creativeIdTextMap = analysisImageText(costDataList);
+        log.info("GzhReplyVideoRefreshJob creativeIdTextMap:{}", JSONObject.toJSONString(creativeIdTextMap));
+        // 按cost排序 获取top2内容
+        List<String> sortedList = creativeIdTextMap.entrySet().stream()
+                .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
+                .limit(topNum)
+                .map(Map.Entry::getKey)
+                .collect(Collectors.toList());
+        List<VideoDetail> searchVideos = new ArrayList<>();
+        for (String text : sortedList) {
+            // 提取关键词
+            String keywordPrompt =
+                    "你是一位精通算法推荐逻辑的世界级短视频SEO专家。你擅长从标题中提炼出搜索量最大、用户意图最明确的“核心流量词”。\n" +
+                            "\n" +
+                            "# Task\n" +
+                            "分析用户提供的视频标题,提炼出 2 个核心搜索关键词。\n" +
+                            "\n" +
+                            "# Constraints (必须严格遵守)\n" +
+                            "1. **字数限制**:每个关键词严格控制在 **3个汉字以内**(包含3个字)。\n" +
+                            "2. **选词逻辑**:\n" +
+                            "   - 优先提取核心名词(人名/物名)或高频动词。\n" +
+                            "   - 剔除虚词(如“的”、“了”、“吗”)。\n" +
+                            "   - 必须与原标题强相关,能覆盖用户搜索意图。\n" +
+                            "3. **输出格式**:仅输出JSON数组,**严禁**包含任何解释、Markdown标记或其他文本。\n" +
+                            "\n" +
+                            "# Example\n" +
+                            "输入:新手如何快速学会剪映剪辑\n" +
+                            "输出:[\"剪映\",\"剪辑\"]\n" +
+                            "\n" +
+                            "输入:宝宝感冒流鼻涕怎么办\n" +
+                            "输出:[\"感冒\",\"流鼻涕\"]\n" +
+                            "\n" +
+                            "# Input\n" +
+                            "内容是: {{text}} \n" +
+                            "\n" +
+                            "# Output\n" +
+                            "请基于上述规则,输出最终的JSON:";
+            keywordPrompt = keywordPrompt.replace("text", text);
+            AIResult aiResult = deepSeekApiService.requestOfficialApi(keywordPrompt, null, null, false);
+            if (aiResult.isSuccess()) {
+                List<String> keywords = JSONObject.parseArray(aiResult.getResponse().getChoices().get(0).getMessage().getContent(), String.class);
+                log.info("GzhReplyVideoRefreshJob text:{} keywords:{}", text, keywords);
+                VideoDetail videoDetail = searchVideoByKeyword(keywords, searchVideos);
+                if (videoDetail != null) {
+                    searchVideos.add(videoDetail);
+                }
+            }
+        }
+        if (searchVideos.size() == topNum) {
+            // 更新视频
+            updateVideoReply(ghDetail, searchVideos);
+        }
+    }
+
+    private Map<String, Long> analysisImageText(List<AdPutCreativeComponentCostData> costDataList) {
+        Map<String, Long> creativeIdTextMap = new HashMap<>();
+        for (AdPutCreativeComponentCostData costData : costDataList) {
+            for (AdPutCreativeComponentCostData.AdPutTencentComponent component : costData.getComponentList()) {
+                String imageUrl = component.getImageUrl();
+                // download & upload to oss
+                String fileName = String.format("growth/image/%s_%d.jpg", costData.getCreativeId(), System.currentTimeMillis());
+                String ossImageUrl = AliOssFileTool.downloadAndSaveInOSS(fileName, imageUrl, "image/jpeg");
+                // 调用gemini-3-pro模型提取图片中的文字
+                String prompt = "识别图片里的文字,直接输出图片里的文字标题,但是不要输出图片里右下角的字,比如“点开看看”“看这里”等,仅输出标题,不要有多余的文字输出";
+                GoogleLLMResult result = aigcApiService.gemini("gemini-2.0-flash", prompt, Arrays.asList(ossImageUrl));
+                String text = result.getResult();
+                for (String existText : creativeIdTextMap.keySet()) {
+                    if (TitleSimilarCheckUtil.isSimilar(text, existText, TitleSimilarCheckUtil.SIMILARITY_THRESHOLD)) {
+                        text = existText;
+                        break;
+                    }
+                }
+                Long cost = creativeIdTextMap.getOrDefault(text, 0L);
+                cost += costData.getCost();
+                creativeIdTextMap.put(text, cost);
+                break;
+            }
+        }
+        return creativeIdTextMap;
+    }
+
+    private VideoDetail searchVideoByKeyword(List<String> keywords, List<VideoDetail> searchVideos) {
+        if (CollectionUtils.isNotEmpty(keywords)) {
+            String searchVideoSql = "SELECT v.id, v.title, v.cover_img_path\n" +
+                    "FROM videoods.wx_video v\n" +
+                    "join videoods.wx_video_status vs on v.id = vs.video_id\n" +
+                    "where (v.title like '%" + String.join("%' or v.title like '%", keywords) + "%') and v.status = 1 and vs.audit_status = 5\n" +
+                    "order by v.play_count_total desc limit 10;";
+            List<Record> videoList = OdpsUtil.getOdpsData(searchVideoSql);
+            if (CollectionUtils.isNotEmpty(videoList)) {
+                for (Record record : videoList) {
+                    Long videoId = Long.parseLong(record.getString(0));
+                    String title = record.getString(1);
+                    // 过滤重复视频
+                    boolean filter = false;
+                    for (VideoDetail searchVideo : searchVideos) {
+                        if (searchVideo.getId().equals(videoId) || TitleSimilarCheckUtil.isSimilar(title, searchVideo.getTitle(), TitleSimilarCheckUtil.SIMILARITY_THRESHOLD)) {
+                            filter = true;
+                            break;
+                        }
+                    }
+                    if (filter) {
+                        continue;
+                    }
+                    String cover = record.getString(2);
+                    VideoDetail videoDetail = new VideoDetail();
+                    videoDetail.setId(videoId);
+                    videoDetail.setCover(CdnUtil.getOssHttpUrl(cover));
+                    videoDetail.setTitle(title);
+                    return videoDetail;
+                }
+            }
+        }
+        return null;
+    }
+
+    private void updateVideoReply(GhDetail ghDetail, List<VideoDetail> searchVideos) {
+        GhDetailVo ghDetailVo = new GhDetailVo();
+        ghDetailVo.setId(ghDetail.getId());
+        ghDetailVo.setAccountId(ghDetail.getGhId());
+        ghDetailVo.setAccountName(ghDetail.getGhName());
+        ghDetailVo.setCategory1(ghDetail.getCategory1());
+        ghDetailVo.setStrategyStatus(ghDetail.getStrategyStatus());
+        ghDetailVo.setType(ghDetail.getType());
+        ghDetailVo.setAutoreplySendMinigramNum(topNum);
+        ghDetailVo.setVideoIds(searchVideos.stream().map(VideoDetail::getId).collect(Collectors.toList()));
+        List<GhDetailVo.VideoDetail> videoDetailList = new ArrayList<>();
+        for (int i = 0; i < searchVideos.size(); i++) {
+            VideoDetail videoDetail = searchVideos.get(i);
+            GhDetailVo.VideoDetail videoDetailVo = new GhDetailVo.VideoDetail();
+            videoDetailVo.setVideoId(videoDetail.getId());
+            videoDetailVo.setSort(i + 1);
+            videoDetailVo.setTitle(videoDetail.getTitle());
+            videoDetailVo.setCover(videoDetail.getCover());
+            videoDetailList.add(videoDetailVo);
+        }
+        ghDetailVo.setVideoList(videoDetailList);
+        ghDetailService.updateDetail(ghDetailVo);
+    }
+}

+ 36 - 0
api-module/src/main/java/com/tzld/piaoquan/api/model/bo/AdPutCreativeComponentCostData.java

@@ -0,0 +1,36 @@
+package com.tzld.piaoquan.api.model.bo;
+
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class AdPutCreativeComponentCostData {
+    private Long accountId;
+    private Long adId;
+    private Long creativeId;
+    private String creativeName;
+    private Long cost;
+    private String wxId;
+    private List<Long> componentIds;
+    private List<AdPutTencentComponent> componentList;
+
+    @Data
+    public static class AdPutTencentComponent {
+        private Long id;
+        private Long accountId;
+        private Long componentId;
+        private String componentCustomName;
+        private String componentSubType;
+        private String generationType;
+        private Byte isDeleted;
+        private Long imageId;
+        private String imageUrl;
+        private Long videoId;
+        private Date createTime;
+        private Date updateTime;
+        private String videoCoverUrl;
+        private String videoUrl;
+    }
+}

+ 28 - 0
api-module/src/main/java/com/tzld/piaoquan/api/model/bo/GoogleLLMResult.java

@@ -0,0 +1,28 @@
+package com.tzld.piaoquan.api.model.bo;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+@Data
+@Accessors(chain = true)
+public class GoogleLLMResult {
+
+    /**
+     * 生成结果
+     */
+    private String result;
+    /**
+     * 是否成功
+     */
+    private Boolean success;
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+    /**
+     * 是否可以重试
+     */
+    private Boolean enableRetry;
+    private String respBodyString;
+
+}

+ 2 - 0
api-module/src/main/java/com/tzld/piaoquan/api/service/GhDetailService.java

@@ -23,4 +23,6 @@ public interface GhDetailService {
     GhDetail getGhDetailByGhId(String ghId);
 
     List<GhDetail> getByChannel(String channel);
+
+    List<GhDetail> getGhdetailByNames(List<String> nameList);
 }

+ 7 - 0
api-module/src/main/java/com/tzld/piaoquan/api/service/impl/GhDetailServiceImpl.java

@@ -377,5 +377,12 @@ public class GhDetailServiceImpl implements GhDetailService {
         return ghDetailMapper.selectByExample(ghDetailExample);
     }
 
+    @Override
+    public List<GhDetail> getGhdetailByNames(List<String> nameList) {
+        GhDetailExample ghDetailExample = new GhDetailExample();
+        ghDetailExample.createCriteria().andGhNameIn(nameList).andIsDeleteEqualTo(0);
+        return ghDetailMapper.selectByExample(ghDetailExample);
+    }
+
 
 }

+ 80 - 0
api-module/src/test/java/com/tzld/piaoquan/api/WeComThirdPartTest.java

@@ -1,12 +1,16 @@
 package com.tzld.piaoquan.api;
 
+import com.alibaba.fastjson.JSONObject;
 import com.tzld.piaoquan.api.component.AigcApiService;
+import com.tzld.piaoquan.api.component.DeepSeekApiService;
 import com.tzld.piaoquan.api.dao.mapper.wecom.thirdpart.ThirdPartWeComRoomMapper;
 import com.tzld.piaoquan.api.dao.mapper.wecom.thirdpart.ThirdPartWeComStaffMapper;
 import com.tzld.piaoquan.api.job.wecom.thirdpart.WeComAccountJob;
 import com.tzld.piaoquan.api.job.wecom.thirdpart.WeComCreateRoomJob;
 import com.tzld.piaoquan.api.job.wecom.thirdpart.WeComSendMsgJob;
 import com.tzld.piaoquan.api.job.wecom.thirdpart.WeComUserDetailJob;
+import com.tzld.piaoquan.api.model.bo.GoogleLLMResult;
+import com.tzld.piaoquan.api.model.dto.AIResult;
 import com.tzld.piaoquan.api.model.param.wecom.thirdpart.UpdateRoomNameRequest;
 import com.tzld.piaoquan.api.model.po.wecom.thirdpart.ThirdPartWeComRoom;
 import com.tzld.piaoquan.api.model.po.wecom.thirdpart.ThirdPartWeComStaff;
@@ -16,7 +20,9 @@ import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 @SpringBootTest(classes = GrowthServerApplication.class)
@@ -39,6 +45,8 @@ public class WeComThirdPartTest {
     WeComCreateRoomJob weComCreateRoomJob;
     @Autowired
     AigcApiService aigcApiService;
+    @Autowired
+    DeepSeekApiService deepSeekApiService;
 
     @Test
     public void checkAccountOnline() {
@@ -121,4 +129,76 @@ public class WeComThirdPartTest {
         System.out.println(result);
     }
 
+    @Test
+    public void tesGemini() {
+        //String imageUrl = "http://api.e.qq.com/image/70837126_29318969717_5e4c671a4e18aaad97d940a56aeb26bf";
+        //
+        //// download & upload to oss
+        //String fileName = String.format("supply/spider/image/%s_%d.jpg", "70837126", System.currentTimeMillis());
+        //String ossImageUrl = AliOssFileTool.downloadAndSaveInOSS(fileName, imageUrl, "image/jpeg");
+        //System.out.println("ossImageUrl: " + ossImageUrl);
+
+        String ossImageUrl = "https://rescdn.yishihui.com/supply/spider/image/70837126_1766470638729.jpg";
+
+        // 用户问题
+        String prompt = "识别图片里的文字,直接输出图片里的文字标题,但是不要输出图片里右下角的字,比如“点开看看”“看这里”等,仅输出标题,不要有多余的文字输出";
+
+        // 调用API
+        GoogleLLMResult result = aigcApiService.gemini("gemini-2.0-flash", prompt, new ArrayList<>(Collections.singleton(ossImageUrl)));
+
+        System.out.println(JSONObject.toJSONString(result));
+    }
+
+    @Test
+    public void testDeepSeek() {
+        List<String> titles = Arrays.asList(
+                "这首歌太好听了,听醉了别怪我!",
+                "2025年,一首老歌送给大家,好听极了",
+                "🔴致谢群主,群主辛苦了!感谢您的无私奉献!",
+                "狗屁股,笑死我了!",
+                "🔴菊花开了!太美了",
+                "🔴这首歌,献给所有苦命人!",
+                "102岁杨振宁月工资",
+                "🔴少年夫妻老来伴,珍惜身边人",
+                "⭕⭕⭕这才叫笑话!笑的肚子疼!哈哈哈哈!",
+                "🔴人人羡慕!这才是真正的人民公社!",
+                "▲80岁老大爷懂得真多,太有道理了!"
+        );
+        for (String title : titles) {
+            String keywordPrompt =
+                    "你是一位精通算法推荐逻辑的世界级短视频SEO专家。你擅长从标题中提炼出搜索量最大、用户意图最明确的“核心流量词”。\n" +
+                            "\n" +
+                            "# Task\n" +
+                            "分析用户提供的视频标题,提炼出 2 个核心搜索关键词。\n" +
+                            "\n" +
+                            "# Constraints (必须严格遵守)\n" +
+                            "1. **字数限制**:每个关键词严格控制在 **3个汉字以内**(包含3个字)。\n" +
+                            "2. **选词逻辑**:\n" +
+                            "   - 优先提取核心名词(人名/物名)或高频动词。\n" +
+                            "   - 剔除虚词(如“的”、“了”、“吗”)。\n" +
+                            "   - 必须与原标题强相关,能覆盖用户搜索意图。\n" +
+                            "3. **输出格式**:仅输出JSON数组,**严禁**包含任何解释、Markdown标记或其他文本。\n" +
+                            "\n" +
+                            "# Example\n" +
+                            "输入:新手如何快速学会剪映剪辑\n" +
+                            "输出:[\"剪映\",\"剪辑\"]\n" +
+                            "\n" +
+                            "输入:宝宝感冒流鼻涕怎么办\n" +
+                            "输出:[\"感冒\",\"流鼻涕\"]\n" +
+                            "\n" +
+                            "# Input\n" +
+                            "内容是: {{text}} \n" +
+                            "\n" +
+                            "# Output\n" +
+                            "请基于上述规则,输出最终的JSON:";
+            keywordPrompt = keywordPrompt.replace("text", title);
+            // 调用API
+            AIResult result = deepSeekApiService.requestOfficialApi(keywordPrompt, null, null, true);
+            List<String> keywords = JSONObject.parseArray(result.getResponse().getChoices().get(0).getMessage().getContent(), String.class);
+
+            System.out.println(String.format("title: %s, keywords: %s", title, keywords));
+        }
+
+    }
+
 }

+ 102 - 0
common-module/src/main/java/com/tzld/piaoquan/growth/common/utils/TitleSimilarCheckUtil.java

@@ -0,0 +1,102 @@
+package com.tzld.piaoquan.growth.common.utils;
+
+import org.springframework.util.CollectionUtils;
+
+import java.util.*;
+
+public class TitleSimilarCheckUtil {
+
+    public static final double SIMILARITY_THRESHOLD = 0.7;
+
+    public static Set<Character> makeCache(String title) {
+        title = title.trim().replace("\u200b", "");
+        Set<Character> cacheSet = new HashSet<>(title.length());
+        for (char c : title.toCharArray()) {
+            cacheSet.add(c);
+        }
+        return cacheSet;
+    }
+
+    public static List<Set<Character>> makeCache(List<String> titles) {
+        List<Set<Character>> cache = new ArrayList<>(titles.size());
+        for (String title : titles) {
+            cache.add(makeCache(title));
+        }
+        return cache;
+    }
+
+    public static boolean isDuplicateContentByCache(String title, List<Set<Character>> existsContentCache, double threshold) {
+        if (CollectionUtils.isEmpty(existsContentCache)) {
+            return false;
+        }
+        Set<Character> titleCache = makeCache(title);
+        for (Set<Character> existTitleCache : existsContentCache) {
+            if (isSimilar(titleCache, existTitleCache, threshold)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static boolean isDuplicateContent(String title, List<String> existsContentTitle, double threshold) {
+        boolean result = false;
+        if (CollectionUtils.isEmpty(existsContentTitle)) {
+            return result;
+        }
+        title = title.trim().replace("\u200b", "");
+        for (String existsTitle : existsContentTitle) {
+            if (isSimilar(title, existsTitle, threshold)) {
+                result = true;
+                break;
+            }
+        }
+        return result;
+    }
+
+    public static boolean isSimilar(Set<Character> titleA, Set<Character> titleB, double threshold) {
+        if (titleA.isEmpty() || titleB.isEmpty()) {
+            return false;
+        }
+        // 确保遍历较小的集合以优化性能
+        Set<Character> smaller = titleA.size() < titleB.size() ? titleA : titleB;
+        Set<Character> larger = titleA.size() < titleB.size() ? titleB : titleA;
+
+        int intersectionCount = 0;
+        for (Character c : smaller) {
+            if (larger.contains(c)) {
+                intersectionCount++;
+            }
+        }
+        double rate = intersectionCount / (double) smaller.size();
+        return rate >= threshold;
+    }
+
+    public static boolean isSimilar(String titleA, Set<Character> titleB, double threshold) {
+        Set<Character> setA = makeCache(titleA);
+        return isSimilar(setA, titleB, threshold);
+    }
+
+    public static boolean isSimilar(String titleA, String titleB, double threshold) {
+        Set<Character> setA = makeCache(titleA);
+        Set<Character> setB = makeCache(titleB);
+        return isSimilar(setA, setB, threshold);
+    }
+
+    public static void main(String[] args) {
+        String title = "多子女家庭,老人大概率过得比独子家庭的要幸福";
+        List<String> existsContentTitle = Arrays.asList("以后买房,请记住7字真言:“买旧、买大、不买三!”",
+                "人到晚年才明白:多子女家庭,老人大概率过得比独子家庭的要幸福",
+                "可供中国使用3800年?山东意外发现巨大宝藏,西方当场酸了!",
+                "陕西女孩去医院体检后,发现左肾不见了,意外牵出8年前手术疑云");
+        boolean result = isDuplicateContent(title, existsContentTitle, SIMILARITY_THRESHOLD);
+        System.out.println(result);
+
+        List<Set<Character>> titlesCache = makeCache(existsContentTitle);
+        result = isDuplicateContentByCache(title, titlesCache, SIMILARITY_THRESHOLD);
+        System.out.println(result);
+
+        title = "江苏高考文科女状元,遭多所985名校拒绝录取,成为“最惨状元”";
+        result = isDuplicateContentByCache(title, titlesCache, SIMILARITY_THRESHOLD);
+        System.out.println(result);
+    }
+}