import fs from 'node:fs' import type { DouyinSearchResponse } from '@ikenxuan/amagi' import type { DouyinUserListData } from '@template/template/douyin/userlist/components/types' import { format } from 'date-fns' import type { AdapterType, Elements, ImageElement, Message } from 'node-karin' import karin, { common, logger, segment } from 'node-karin' import { Base, baseHeaders, buildGoogleMotionPhoto, cleanOldDynamicCache, Common, douyinDB, downloadFile, downLoadFileOptions, downloadVideo, fileInfo, type LiveImageMergeOptions, loopVideoWithTransition, Networks, processLocalImageFile, processImageUrl, Render } from '@/module' import { Config } from '@/module/utils/Config' import { DouyinIdData, buildDouyinPlayUrl, douyinProcessVideos, type dyVideo, getDouyinID } from '@/platform/douyin' import type { DouyinListItem } from '@/platform/douyin/types' import { getDouyinLiveImageSendPolicy, getWorkTypeDisplayName, getWorkTypeInfo } from '@/platform/douyin/workType' import { parseCommandActions } from '@/module/utils/QqPanel' import type { douyinPushItem } from '@/types/config/pushlist' import { processFavoriteList } from './push/favorite' import { processLiveStream } from './push/live' import { processPostList } from './push/post' import { processRecommendList } from './push/recommend' import { renderFavoriteImage, renderLiveImage, renderRecommendImage, renderWorkImage } from './push/render' import { type DouyinPushItem, type DouyinWorkPushItem, type WillBePushList } from './push/types' // Re-export types for backward compatibility export type { DouyinPushItem, DouyinWorkPushItem } const douyinBaseHeaders: downLoadFileOptions['headers'] = { ...baseHeaders, Referer: 'https://www.douyin.com', Cookie: Config.amagi.cookies.douyin } export class DouYinpush extends Base { private force = false /** * * @param e 事件Message * @param force 是否强制推送 * @default false * @returns */ constructor(e = {} as Message, force: boolean = false) { super(e) this.headers!.Referer = 'https://www.douyin.com' this.headers!.Cookie = Config.amagi.cookies.douyin this.force = force } private injectBotToEventForRender(targets: Array<{ groupId: string; botId: string }>): void { const targetBotId = targets.find((item) => item.botId)?.botId if (!targetBotId) return const bot = karin.getBot(targetBotId) as AdapterType | undefined if (!bot) return const eventWithBot = this.e as Message & { bot?: AdapterType; selfId?: string } eventWithBot.bot = bot eventWithBot.selfId = eventWithBot.selfId ?? targetBotId } /** * 执行主要的操作流程 */ async action() { await this.syncConfigToDatabase() // 检查备注信息 if (await this.checkremark()) return true // 检查并补全配置文件中缺失的字段 await this.ensureConfigFields(Config.pushlist.douyin) // 获取已注册的 bot 列表,过滤未注册的 bot const registeredBotIds = karin.getAllBotID() const filteredPushList = this.filterPushListByRegisteredBots(Config.pushlist.douyin, registeredBotIds) if (filteredPushList.length === 0) { // 推送列表本来就是空的属正常情况,别刷 warn(用户反馈日志一直被它刷屏) if ((Config.pushlist as any).douyin?.length) { logger.warn('推送列表里的机器人都不在线,本次跳过抖音推送') } else { logger.debug('抖音推送列表为空,跳过') } return true } const data = await this.getDynamicList(filteredPushList) // 清理过期作品缓存:必须放在 getDynamicList(内部会为仍在列表中的作品 touch 续期)之后。 // 若放在续期之前,升级后旧记录的 updatedAt 偏早,会在续期前被按 updatedAt 清理掉,导致仍在 // 列表中的历史作品被当作新作品重推一次。 const deletedCount = await cleanOldDynamicCache('douyin') if (deletedCount > 0) { logger.info(`已清理 ${deletedCount} 条过期的抖音作品缓存记录`) } if (Object.keys(data).length === 0) return true if (this.force) return await this.forcepush(data) else return await this.getdata(data) } /** * 检查并补全配置文件中缺失的字段 * @param pushList 推送配置列表 */ private async ensureConfigFields(pushList: douyinPushItem[]): Promise { if (!pushList || pushList.length === 0) return let hasChanges = false for (const item of pushList) { // 检查并补全 sec_uid 字段 if (!item.sec_uid && item.short_id) { try { logger.info(`自动获取用户 ${item.remark || item.short_id} 的 sec_uid`) const searchResult = await this.amagi.douyin.fetcher.searchContent({ query: item.short_id, type: 'user' }) // 在搜索结果中查找匹配的用户 let matchedUser = null for (const userItem of searchResult.data.user_list) { const currentDouyinId = userItem.user_info.unique_id || userItem.user_info.short_id if (currentDouyinId === item.short_id) { matchedUser = userItem.user_info break } } if (matchedUser?.sec_uid) { item.sec_uid = matchedUser.sec_uid hasChanges = true logger.info(`已为 ${item.remark || item.short_id} 补全 sec_uid: ${item.sec_uid}`) } else { logger.warn(`无法获取用户 ${item.short_id} 的 sec_uid`) } } catch (error) { logger.error(`获取 ${item.short_id} 的 sec_uid 失败: ${error}`) } } // 检查并补全 pushTypes 字段 if (!item.pushTypes || item.pushTypes.length === 0) { item.pushTypes = ['post', 'live', 'favorite', 'recommend'] hasChanges = true logger.info(`为用户 ${item.remark ?? item.sec_uid} 自动补全推送类型:作品列表、直播、收藏、推荐`) } // 检查并补全 switch 字段 if (item.switch === undefined) { item.switch = true hasChanges = true } } // 如果有修改,保存到配置文件 if (hasChanges) { Config.Modify('pushlist', 'douyin', pushList) logger.info('已自动补全配置文件中缺失的字段并保存') } } /** * 根据已注册的 bot 列表过滤推送配置 * @param pushList 原始推送配置列表 * @param registeredBotIds 已注册的 bot ID 列表 * @returns 过滤后的推送配置列表 */ private filterPushListByRegisteredBots(pushList: douyinPushItem[], registeredBotIds: string[]): douyinPushItem[] { if (!pushList || pushList.length === 0) return [] const registeredSet = new Set(registeredBotIds) const filteredList: douyinPushItem[] = [] for (const item of pushList) { // 过滤 group_id 中未注册的 bot const filteredGroupIds = item.group_id.filter((groupWithBot) => { const botId = groupWithBot.split(':')[1] const isRegistered = registeredSet.has(botId) if (!isRegistered) { logger.debug(`Bot ${botId} 未注册,跳过群组 ${groupWithBot.split(':')[0]} 的推送`) } return isRegistered }) // 如果过滤后还有有效的群组,则保留该订阅项 if (filteredGroupIds.length > 0) { filteredList.push({ ...item, group_id: filteredGroupIds }) } else { logger.debug(`用户 ${item.remark ?? item.sec_uid} 的所有推送目标 bot 均未注册,跳过`) } } return filteredList } /** * 同步配置文件中的订阅信息到数据库 */ async syncConfigToDatabase() { // 如果配置文件中没有抖音推送列表,直接返回 if (!Config.pushlist.douyin || Config.pushlist.douyin.length === 0) { return } await douyinDB.syncConfigSubscriptions(Config.pushlist.douyin) } async getdata(data: WillBePushList) { if (Object.keys(data).length === 0) return true for (const awemeId in data) { const pushItem = data[awemeId] const actualAwemeId = awemeId.replace(/^(post|favorite|recommend|live)_/, '') // 移除推送类型前缀 const shareUrl = pushItem.pushType === 'live' ? `https://live.douyin.com/${pushItem.Detail_Data.room_data?.owner.web_rid}` : (pushItem.Detail_Data.share_url ?? `https://www.douyin.com/video/${actualAwemeId}`) let pushTypeLabel: string switch (pushItem.pushType) { case 'post': pushTypeLabel = '作品列表' break case 'favorite': pushTypeLabel = '喜欢列表' break case 'recommend': pushTypeLabel = '推荐列表' break default: pushTypeLabel = '直播' break } logger.mark(` ${logger.blue('开始处理并渲染抖音动态图片')} ${logger.blue('博主')}: ${logger.green(pushItem.remark)} ${logger.blue('推送类型')}: ${logger.magenta(pushTypeLabel)} ${logger.cyan('作品id')}:${logger.yellow(actualAwemeId)} ${logger.cyan('访问地址')}:${logger.green('https://www.douyin.com/video/' + actualAwemeId)} ${logger.cyan('分享链接')}: ${logger.green(shareUrl)} `) const Detail_Data = pushItem.Detail_Data const workTypeInfo = getWorkTypeInfo(Detail_Data) const skip = await skipDynamic(pushItem) if (skip) { logger.warn(`作品 https://www.douyin.com/video/${actualAwemeId} 已被处理,跳过`) } let img: ImageElement[] = [] let iddata: DouyinIdData = { type: 'one_work' } /** 按画质配置选中、即将下载发送的那一路视频源 */ let selectedVideo: dyVideo | null = null this.injectBotToEventForRender(pushItem.targets) if (!skip) { iddata = await getDouyinID(this.e, shareUrl, false) } if (!skip) { // 画质选档必须早于渲染和分享链接拼接:卡片上展示的清晰度要和后面实际下载的那一路视频源完全一致, // 否则会出现「卡片写 4K、实际下载 720p」的错位。 if (pushItem.pushType !== 'live' && workTypeInfo.isVideo && pushItem.Detail_Data.video?.bit_rate?.length) { selectedVideo = douyinProcessVideos(pushItem.Detail_Data.video.bit_rate, Config.douyin.videoQuality)[0] } let workShareLink: string | undefined if (pushItem.pushType !== 'live') { if (workTypeInfo.isArticle) { workShareLink = `https://www.douyin.com/article/${actualAwemeId}` } else if (workTypeInfo.isImage) { // 图文和合辑使用无追踪参数的短链接,降低二维码密度并提高扫描识别率。 workShareLink = `https://www.douyin.com/note/${actualAwemeId}` } else if (Config.douyin.push.shareType === 'web') { // 视频同样用无追踪参数的规范链接:share_url 302 之后只是多出一串追踪参数,白搭一次网络请求还会拉高二维码密度 workShareLink = `https://www.douyin.com/video/${actualAwemeId}` } else { // 直链模式:优先用选档后的视频源拼播放地址,二维码内容与卡片展示的清晰度一致 const playAddr = selectedVideo?.play_addr ?? pushItem.Detail_Data.video?.play_addr workShareLink = playAddr ? buildDouyinPlayUrl(playAddr) : `https://www.douyin.com/video/${actualAwemeId}` } } switch (pushItem.pushType) { case 'live': { if (!pushItem.Detail_Data.room_data || !pushItem.Detail_Data.live_data) break img = await renderLiveImage({ e: this.e, Detail_Data: pushItem.Detail_Data, dynamicTypeLabel: '直播动态推送' }) break } case 'favorite': { img = await renderFavoriteImage({ e: this.e, Detail_Data: pushItem.Detail_Data, create_time: pushItem.create_time, shareLink: workShareLink!, remark: pushItem.remark }) break } case 'recommend': { img = await renderRecommendImage({ e: this.e, Detail_Data: pushItem.Detail_Data, create_time: pushItem.create_time, shareLink: workShareLink!, remark: pushItem.remark }) break } case 'post': default: { img = await renderWorkImage({ e: this.e, Detail_Data: pushItem.Detail_Data, create_time: pushItem.create_time, shareLink: workShareLink!, videoSource: selectedVideo }) break } } } // 遍历目标群组,并发送消息 for (const target of pushItem.targets) { let status = { message_id: '' } const { groupId, botId } = target if (!skip) { const Contact = karin.contactGroup(groupId) // 为当前目标注入 bot,后续解析下载沿用该 bot 身份 const bot = karin.getBot(botId) as AdapterType const eventWithBot = this.e as Message & { bot?: AdapterType; selfId?: string } eventWithBot.bot = bot eventWithBot.selfId = botId const pushImg = img ?? [] /** * 卡片末尾追加「解析」入口(直播推送没有作品链接,不给按钮)。 * * 以前用 `segment.button`(旧式回调按钮)且只认 `adapter.name === 'QQ Official Bot'`, * 很多部署上根本不出按钮。现在和 B站推送同一套写法: * QQ / QQ 频道给 markdown 按钮,其它平台给「引用这条消息发送指令」的文字提示。 */ const parseButton = pushItem.pushType !== 'live' ? parseCommandActions({ bot }, pushItem.Detail_Data.share_url) : [] // 发送消息 status = await karin.sendMsg(botId, Contact, [...pushImg, ...parseButton]) // 如果是直播推送,更新直播状态 if (pushItem.pushType === 'live' && 'room_data' in pushItem.Detail_Data && status.message_id) { await douyinDB.updateLiveStatus(pushItem.sec_uid, true) } // 是否一同解析该新作品?(直播推送没有可解析的作品内容) if (pushItem.pushType !== 'live' && Config.douyin.push.parsedynamic && status.message_id) { // 收窄为作品类推送的作品详情 const Detail_Data = pushItem.Detail_Data logger.debug(`开始解析作品,类型为:${getWorkTypeDisplayName(workTypeInfo)}`) // 如果新作品是视频 if (workTypeInfo.isVideo && Detail_Data.video) { /** 默认视频下载地址 */ let downloadUrl = buildDouyinPlayUrl(Detail_Data.video.play_addr) // 根据配置文件自动选择分辨率 logger.debug(`开始排除不符合条件的视频分辨率;\n 共拥有${logger.yellow(Detail_Data.video.bit_rate.length)}个视频源\n 视频ID:${logger.green(Detail_Data.aweme_id)}\n 分享链接:${logger.green(Detail_Data.share_url)} `) // 复用渲染前已选好的视频源,卡片展示的清晰度与实际下载的必然一致 const videoObj = selectedVideo ?? douyinProcessVideos(Detail_Data.video.bit_rate, Config.douyin.videoQuality)[0] logger.debug('获取精确下载地址') downloadUrl = await new Networks({ url: videoObj.play_addr.url_list[0], headers: douyinBaseHeaders }).getLongLink() // 下载视频 await downloadVideo( this.e, { video_url: downloadUrl, title: { timestampTitle: `tmp_${Date.now()}.mp4`, originTitle: `${Detail_Data.desc}.mp4` } }, { active: true, activeOption: { uin: botId, group_id: groupId } } ) } else if (workTypeInfo.isImage && iddata.type === 'one_work') { // 如果新作品是图集或合辑 // 判断是否为合辑(is_slides) const isSlides = Detail_Data.is_slides === true if (isSlides && Detail_Data.images) { // 合辑处理逻辑 const images: Elements[] = [] const temp: fileInfo[] = [] let hasGeneratedLivePhoto = false // 标记是否生成了实况图 /** 下载 BGM(如果存在) */ let liveimgbgm: fileInfo | null = null let bgmContext: LiveImageMergeOptions['context'] | null = null const mergeMode = Config.douyin.liveImageMergeMode ?? 'independent' if (Detail_Data.music) { let mp3Path = '' // 该声音由于版权原因在当前地区不可用 if (Detail_Data.music.play_url.uri === '') { const extraData = JSON.parse(Detail_Data.music.extra) mp3Path = extraData.original_song_url } else { mp3Path = Detail_Data.music.play_url.uri } liveimgbgm = await downloadFile(mp3Path, { title: `Douyin_tmp_A_${Date.now()}.mp3`, headers: douyinBaseHeaders }) temp.push(liveimgbgm) } const images1 = Detail_Data.images ?? [] if (!images1.length) { logger.debug('未获取到合辑的图片数据') } for (const [index, item] of images1.entries()) { // 静态图片,clip_type为2或undefined if (item.clip_type === 2 || item.clip_type === undefined) { const imageUrl = await processImageUrl(item.url_list[0], Detail_Data.desc, index) images.push(segment.image(imageUrl)) continue } /** 动图/短片 */ const livePlayAddr = item.video?.play_addr_h264 if (!livePlayAddr?.uri) { logger.warn(`合辑第 ${index + 1} 个动态媒体缺少视频源,跳过`) continue } const liveimg = await downloadFile(buildDouyinPlayUrl(livePlayAddr), { title: `Douyin_tmp_V_${Date.now()}.mp4`, headers: douyinBaseHeaders }) if (liveimg.filepath) { const outputPath = Common.tempDri.video + `Douyin_Result_${Date.now()}.mp4` const loopCount = item.clip_type === 4 ? 1 : 3 let staticImgPath = '' if (item.url_list?.[0]) { const staticImg = await downloadFile(item.url_list[0], { title: `Douyin_static_${Date.now()}_${index}.jpg`, headers: douyinBaseHeaders, filepath: Common.tempDri.images + `Douyin_static_${Date.now()}_${index}.jpg` }) if (staticImg.filepath) { temp.push({ filepath: staticImg.filepath, totalBytes: 0 }) } staticImgPath = staticImg.filepath ?? '' } const { shouldGenerateVideo, shouldGenerateLivePhoto } = getDouyinLiveImageSendPolicy( item.clip_type, Config.app.livePhotoMode ?? 'video_and_livephoto' ) // 生成视频 if (shouldGenerateVideo) { const transitionEnabled = loopCount > 1 && Boolean(staticImgPath) const safeStaticPath = staticImgPath || liveimg.filepath const result = await loopVideoWithTransition({ inputPath: liveimg.filepath, outputPath, loopCount, staticImagePath: safeStaticPath, transitionEnabled, bgmPath: liveimgbgm?.filepath, mergeMode, context: bgmContext ?? undefined }) const success = result.success if (mergeMode === 'continuous' && result.context) { bgmContext = result.context } if (success) { const filePath = Common.tempDri.video + `tmp_${Date.now()}.mp4` fs.renameSync(outputPath, filePath) logger.mark(`视频文件重命名完成: ${outputPath.split('/').pop()} -> ${filePath.split('/').pop()}`) temp.push({ filepath: filePath, totalBytes: 0 }) const videoPath = Config.app.videoSendMode === 'base64' ? `base64://${fs.readFileSync(filePath).toString('base64')}` : `file://${filePath}` images.push(segment.video(videoPath)) } } // 生成实况图(clip_type === 5 是 livePhoto) if (shouldGenerateLivePhoto && item.clip_type === 5 && item.url_list?.[0]) { let hasPushedMotionPhotoCover = false if (staticImgPath) { const motionPhotoCoverPath = Common.tempDri.images + `MVIMG_${format(new Date(), 'yyyyMMdd_HHmmss_SSS')}_${index}.jpg` const motionPhotoCreated = await buildGoogleMotionPhoto({ imagePath: staticImgPath, videoPath: liveimg.filepath, outputPath: motionPhotoCoverPath }) if (motionPhotoCreated) { temp.push({ filepath: motionPhotoCoverPath, totalBytes: 0 }) const motionPhotoCover = processLocalImageFile(motionPhotoCoverPath) images.push(segment.image(motionPhotoCover)) hasPushedMotionPhotoCover = true } } if (!hasPushedMotionPhotoCover) { const imageUrl = await processImageUrl(item.url_list[0], Detail_Data.desc, index) images.push(segment.image(imageUrl)) } else { hasGeneratedLivePhoto = true // 标记已生成实况图 } } logger.mark('正在尝试删除缓存文件') await Common.removeFile(liveimg.filepath, true) } } // 如果生成了实况图,添加提示文字 if (hasGeneratedLivePhoto) { const tipImg = await Render(this.e, 'other/live-photo-tip', { title: '实况照片已生成', description: '保存原图到相册即可识别为实况图' }) images.push(...tipImg) } const bot = karin.getBot(botId) as AdapterType try { if (images.length === 0) { logger.warn(`抖音合辑推送解析未生成可发送内容,aweme_id=${Detail_Data.aweme_id}`) } else { const Element = common.makeForward(images, botId, bot.account.name) await bot.sendForwardMsg(Contact, Element, { source: '合辑内容', summary: `查看${Element.length}张图片/视频消息`, prompt: '抖音合辑解析结果', news: [{ text: '点击查看解析结果' }] }) } } catch (error) { logger.error(`发送合辑失败: ${error}`) } finally { for (const item of temp) { await Common.removeFile(item.filepath, true) } } } else if (Detail_Data.images) { // 普通图集处理逻辑 // 检查是否包含 live 图(clip_type !== 2 且 clip_type !== undefined) const hasLiveImage = Detail_Data.images.some((item) => item.clip_type !== 2 && item.clip_type !== undefined) if (hasLiveImage) { // 包含 live 图,需要特殊处理 const processedImages: Elements[] = [] const temp: fileInfo[] = [] let hasGeneratedLivePhoto = false // 标记是否生成了实况图 /** 下载 BGM(如果存在) */ let liveimgbgm: fileInfo | null = null let bgmContext: LiveImageMergeOptions['context'] | null = null const mergeMode = Config.douyin.liveImageMergeMode ?? 'independent' if (Detail_Data.music) { let mp3Path = '' if (Detail_Data.music.play_url.uri === '') { const extraData = JSON.parse(Detail_Data.music.extra) mp3Path = extraData.original_song_url } else { mp3Path = Detail_Data.music.play_url.uri } liveimgbgm = await downloadFile(mp3Path, { title: `Douyin_tmp_A_${Date.now()}.mp3`, headers: douyinBaseHeaders }) temp.push(liveimgbgm) } for (const [index, item] of Detail_Data.images.entries()) { // 静态图片,clip_type为2或undefined if (item.clip_type === 2 || item.clip_type === undefined) { const image_url = item.url_list[2] ?? item.url_list[1] if (!image_url) continue const imageUrl = await processImageUrl(image_url, Detail_Data.desc, index) processedImages.push(segment.image(imageUrl)) continue } /** live 图 */ const livePlayAddr = item.video?.play_addr_h264 if (!livePlayAddr?.uri) { logger.warn(`图集第 ${index + 1} 个动态媒体缺少视频源,跳过`) continue } const liveimg = await downloadFile(buildDouyinPlayUrl(livePlayAddr), { title: `Douyin_tmp_V_${Date.now()}.mp4`, headers: douyinBaseHeaders }) if (liveimg.filepath) { const outputPath = Common.tempDri.video + `Douyin_Result_${Date.now()}.mp4` const loopCount = item.clip_type === 4 ? 1 : 3 let staticImgPath = '' if (item.url_list?.[0]) { const staticImg = await downloadFile(item.url_list[0], { title: `Douyin_static_${Date.now()}_${index}.jpg`, headers: douyinBaseHeaders, filepath: Common.tempDri.images + `Douyin_static_${Date.now()}_${index}.jpg` }) if (staticImg.filepath) { temp.push({ filepath: staticImg.filepath, totalBytes: 0 }) } staticImgPath = staticImg.filepath ?? '' } const { shouldGenerateVideo, shouldGenerateLivePhoto } = getDouyinLiveImageSendPolicy( item.clip_type, Config.app.livePhotoMode ?? 'video_and_livephoto' ) // 生成视频 if (shouldGenerateVideo) { const transitionEnabled = loopCount > 1 && Boolean(staticImgPath) const safeStaticPath = staticImgPath || liveimg.filepath const result = await loopVideoWithTransition({ inputPath: liveimg.filepath, outputPath, loopCount, staticImagePath: safeStaticPath, transitionEnabled, bgmPath: liveimgbgm?.filepath, mergeMode, context: bgmContext ?? undefined }) const success = result.success if (mergeMode === 'continuous' && result.context) { bgmContext = result.context } if (success) { const filePath = Common.tempDri.video + `tmp_${Date.now()}.mp4` fs.renameSync(outputPath, filePath) logger.mark(`视频文件重命名完成: ${outputPath.split('/').pop()} -> ${filePath.split('/').pop()}`) temp.push({ filepath: filePath, totalBytes: 0 }) const videoPath = Config.app.videoSendMode === 'base64' ? `base64://${fs.readFileSync(filePath).toString('base64')}` : `file://${filePath}` processedImages.push(segment.video(videoPath)) } } // 生成实况图(clip_type === 5 是 livePhoto) if (shouldGenerateLivePhoto && item.clip_type === 5 && item.url_list?.[0]) { let hasPushedMotionPhotoCover = false if (staticImgPath) { const motionPhotoCoverPath = Common.tempDri.images + `MVIMG_${format(new Date(), 'yyyyMMdd_HHmmss_SSS')}_${index}.jpg` const motionPhotoCreated = await buildGoogleMotionPhoto({ imagePath: staticImgPath, videoPath: liveimg.filepath, outputPath: motionPhotoCoverPath }) if (motionPhotoCreated) { temp.push({ filepath: motionPhotoCoverPath, totalBytes: 0 }) const motionPhotoCover = processLocalImageFile(motionPhotoCoverPath) processedImages.push(segment.image(motionPhotoCover)) hasPushedMotionPhotoCover = true } } if (!hasPushedMotionPhotoCover) { const imageUrl = await processImageUrl(item.url_list[0], Detail_Data.desc, index) processedImages.push(segment.image(imageUrl)) } else { hasGeneratedLivePhoto = true // 标记已生成实况图 } } logger.mark('正在尝试删除缓存文件') await Common.removeFile(liveimg.filepath, true) } } // 如果生成了实况图,添加提示文字 if (hasGeneratedLivePhoto) { const tipImg = await Render(this.e, 'other/live-photo-tip', { title: '实况照片已生成', description: '保存原图到相册即可识别为实况图' }) processedImages.push(...tipImg) } const bot = karin.getBot(botId) as AdapterType try { if (processedImages.length === 0) { logger.warn(`抖音图集推送解析未生成可发送内容,aweme_id=${Detail_Data.aweme_id}`) } else { const Element = common.makeForward(processedImages, botId, bot.account.name) await bot.sendForwardMsg(Contact, Element, { source: '图集内容', summary: `查看${Element.length}张图片/视频消息`, prompt: '抖音图集解析结果', news: [{ text: '点击查看解析结果' }] }) } } catch (error) { logger.error(`发送图集失败: ${error}`) } finally { for (const item of temp) { await Common.removeFile(item.filepath, true) } } } else { // 纯静态图集 const imageres: ImageElement[] = [] let image_url for (const [index, item] of Detail_Data.images.entries()) { image_url = item.url_list[2] ?? item.url_list[1] // 图片地址 if (!image_url) continue const imageUrl = await processImageUrl(image_url, Detail_Data.desc, index) imageres.push(segment.image(imageUrl)) } const bot = karin.getBot(botId) as AdapterType if (imageres.length === 1 && image_url) { // 单张图片直接发送 const imageUrl = await processImageUrl(image_url, Detail_Data.desc) await bot.sendMsg(Contact, [segment.image(imageUrl)]) } else { // 多张图片使用合并转发 const forwardMsg = common.makeForward(imageres, botId, bot.account.name) await bot.sendForwardMsg(Contact, forwardMsg, { source: '图片合集', summary: `查看${forwardMsg.length}张图片消息`, prompt: '抖音图集解析结果', news: [{ text: '点击查看解析结果' }] }) } } } } } } // 添加作品缓存(直播不需要缓存aweme_id) if (skip || (pushItem.pushType !== 'live' && status.message_id)) { await douyinDB.addAwemeCache(actualAwemeId, pushItem.sec_uid, groupId, pushItem.pushType) } } } return true } /** * 根据配置文件获取用户当天的作品列表。 * @returns 将要推送的列表 */ async getDynamicList(userList: douyinPushItem[]): Promise { const willbepushlist: WillBePushList = {} try { /** 过滤掉不启用的订阅项 */ const filteredUserList = userList.filter((item) => item.switch !== false) for (const item of filteredUserList) { await common.sleep(2000) if (!item.sec_uid) { logger.warn(`用户 ${item.remark || item.short_id} 缺少 sec_uid,跳过`) continue } const sec_uid = item.sec_uid const pushTypes = item.pushTypes || ['post'] // 默认推送作品列表 logger.debug(`开始获取用户:${item.remark}(${sec_uid})的内容,推送类型:${pushTypes.join(', ')}`) const userinfo = await this.amagi.douyin.fetcher.fetchUserProfile({ sec_uid }) const targets = item.group_id.map((groupWithBot) => { const [groupId, botId] = groupWithBot.split(':') return { groupId, botId } }) // 如果没有订阅群组,跳过该用户 if (targets.length === 0) continue // special_state 特殊状态,用户已注销 if (userinfo.data.user?.special_state_info?.special_state === 1 && userinfo.data.user?.user_deleted === true) { logger.warn(`${item.remark}(${sec_uid})${userinfo.data.user.special_state_info.title}`) continue } // 遍历每种推送类型 for (const pushType of pushTypes) { await common.sleep(1000) // 处理直播推送 if (pushType === 'live') { const liveItem = await processLiveStream(sec_uid, userinfo.data, item, targets, this.amagi) if (liveItem) { willbepushlist[`live_${sec_uid}`] = liveItem } continue } let contentList: DouyinListItem[] = [] let listName = '' // 根据推送类型获取不同的列表 switch (pushType) { case 'post': listName = '作品列表' const videolist = await this.amagi.douyin.fetcher.fetchUserVideoList({ sec_uid, number: 15 }) contentList = videolist.data.aweme_list || [] break case 'favorite': listName = '喜欢列表' const favoritelist = await this.amagi.douyin.fetcher.fetchUserFavoriteList({ sec_uid, number: 15 }) if (favoritelist.data.aweme_list.length === 0) logger.warn(`${item.remark}(${item.short_id}) 获取到的喜欢列表数量为零!此博主可能未公开他/她的喜欢列表`) contentList = favoritelist.data.aweme_list || [] break case 'recommend': listName = '推荐列表' const recommendlist = await this.amagi.douyin.fetcher.fetchUserRecommendList({ sec_uid, number: 15 }) if (recommendlist.data.aweme_list.length === 0) logger.warn(`${item.remark}(${item.short_id}) 获取到的推荐列表数量为零!此博主可能未公开他/她的推荐列表`) contentList = recommendlist.data.aweme_list || [] break } logger.debug(`获取到 ${item.remark} 的${listName},共 ${contentList.length} 条`) // 根据推送类型调用不同的处理函数 if (contentList.length > 0) { let pushItems: DouyinWorkPushItem[] = [] switch (pushType) { case 'post': pushItems = await processPostList(contentList, sec_uid, userinfo.data, item, targets) break case 'favorite': pushItems = await processFavoriteList(contentList, sec_uid, userinfo.data, item, targets, this.force) break case 'recommend': pushItems = await processRecommendList(contentList, sec_uid, userinfo.data, item, targets, this.force) break } // 将返回的推送项添加到willbepushlist中 for (const pushItem of pushItems) { const key = `${pushType}_${pushItem.Detail_Data.aweme_id}` willbepushlist[key] = pushItem } } } } } catch (error) { throw new Error(`获取抖音用户内容列表失败: ${error}`) } return willbepushlist } /** * 检查作品是否已经推送过 * @param aweme_id 作品ID * @param sec_uid 用户sec_uid * @param groupIds 群组ID列表 * @param pushType 推送类型 * @returns 是否已经推送过 */ async checkIfAlreadyPushed(aweme_id: string, sec_uid: string, groupIds: string[], pushType: string = 'post'): Promise { for (const groupId of groupIds) { const isPushed = await douyinDB.isAwemePushed(aweme_id, sec_uid, groupId, pushType) if (!isPushed) { return false } } return true } /** * 设置或更新特定 sec_uid 的群组信息。 * @param data 抖音的搜索结果数据。需要接口返回的原始数据 * @returns 操作成功或失败的消息字符串。 */ async setting(data: DouyinSearchResponse): Promise { const groupInfo = await this.e.bot.getGroupInfo('groupId' in this.e && this.e.groupId ? this.e.groupId : '') const config = Config.pushlist // 读取配置文件 const groupId = 'groupId' in this.e && this.e.groupId ? this.e.groupId : '' const botId = this.e.selfId try { // 获取用户输入的抖音号 const inputDouyinId = this.e.msg.replace(/^#设置抖音推送/, '').trim() /** * 搜索结果按响应形态自述(判别式 `__search_type`,由 amagi 的 normalize 按响应结构 + * 请求类型写入)。订阅只认「用户搜索」那一支:抖音把 user 请求回成别的形态时在这里说清楚, * 而不是拿一个 undefined 去遍历。 */ if (data.__search_type !== 'user') { throw new Error(`抖音没有返回用户搜索结果(返回形态:${data.__search_type ?? '未知'})`) } // 在用户列表中查找匹配的用户 let matchedUser = null for (const userItem of data.user_list) { const currentDouyinId = userItem.user_info.unique_id === '' ? userItem.user_info.short_id : userItem.user_info.unique_id if (currentDouyinId === inputDouyinId) { matchedUser = userItem.user_info break } } // 如果没找到匹配的用户,抛出错误 if (!matchedUser) { throw new Error(`未找到抖音号为 ${inputDouyinId} 的用户`) } // 使用匹配到的用户的 sec_uid 进行下一步请求 const sec_uid = matchedUser.sec_uid const UserInfoData = await this.amagi.douyin.fetcher.fetchUserProfile({ sec_uid }) /** 处理抖音号 */ let user_shortid user_shortid = UserInfoData.data.user.unique_id === '' ? UserInfoData.data.user.short_id : UserInfoData.data.user.unique_id // 初始化 douyin 数组 config.douyin ??= [] // 查找是否存在相同的 sec_uid const existingItem = config.douyin.find((item: { sec_uid: string }) => item.sec_uid === sec_uid) // 检查数据库中是否已订阅 const isSubscribed = await douyinDB.isSubscribed(sec_uid, groupId) if (existingItem) { // 如果已经存在相同的 sec_uid,则检查是否存在相同的 group_id let has = false let groupIndexToRemove = -1 // 用于记录要删除的 group_id 对象的索引 for (let index = 0; index < existingItem.group_id.length; index++) { // 分割每个对象的 id 属性,并获取第一部分 const item = existingItem.group_id[index] const existingGroupId = item.split(':')[0] // 检查分割后的第一部分是否与提供的 group_id 相同 if (existingGroupId === String(groupId)) { has = true groupIndexToRemove = index break // 找到匹配项后退出循环 } } if (has) { // 如果存在相同的 group_id,则删除它 existingItem.group_id.splice(groupIndexToRemove, 1) // 同时从数据库中取消订阅 if (isSubscribed) { await douyinDB.unsubscribeDouyinUser(groupId, sec_uid) } // 如果删除后 group_id 数组为空,则删除整个属性 if (existingItem.group_id.length === 0) { const index = config.douyin.indexOf(existingItem) config.douyin.splice(index, 1) } // 保存配置到文件 Config.Modify('pushlist', 'douyin', config.douyin) await this.e.reply( `群:${groupInfo.groupName}(${groupId})\n删除成功!${UserInfoData.data.user.nickname}\n抖音号:${user_shortid}` ) logger.info(`\n删除成功!${UserInfoData.data.user.nickname}\n抖音号:${user_shortid}\nsec_uid${UserInfoData.data.user.sec_uid}`) } else { // 否则,将新的 group_id 添加到该 sec_uid 对应的数组中 existingItem.group_id.push(`${groupId}:${botId}`) // 确保 pushTypes 字段存在,如果不存在则添加默认值 if (!existingItem.pushTypes || existingItem.pushTypes.length === 0) { existingItem.pushTypes = ['post', 'live'] } // 同时在数据库中添加订阅 if (!isSubscribed) { await douyinDB.subscribeDouyinUser(groupId, botId, sec_uid, user_shortid, UserInfoData.data.user.nickname) } // 保存配置到文件 Config.Modify('pushlist', 'douyin', config.douyin) await this.e.reply( `群:${groupInfo.groupName}(${groupId})\n添加成功!${UserInfoData.data.user.nickname}\n抖音号:${user_shortid}` ) if (Config.douyin.push.switch === false) await this.e.reply('请发送「#设置抖音推送开启」以进行推送') logger.info(`\n设置成功!${UserInfoData.data.user.nickname}\n抖音号:${user_shortid}\nsec_uid${UserInfoData.data.user.sec_uid}`) } } else { // 如果不存在相同的 sec_uid,则新增一个属性 config.douyin.push({ switch: true, sec_uid, group_id: [`${groupId}:${botId}`], remark: UserInfoData.data.user.nickname, short_id: user_shortid, pushTypes: ['post', 'live'] }) // 同时在数据库中添加订阅 if (!isSubscribed) { await douyinDB.subscribeDouyinUser(groupId, botId, sec_uid, user_shortid, UserInfoData.data.user.nickname) } // 保存配置到文件 Config.Modify('pushlist', 'douyin', config.douyin) await this.e.reply(`群:${groupInfo.groupName}(${groupId})\n添加成功!${UserInfoData.data.user.nickname}\n抖音号:${user_shortid}`) if (Config.douyin.push.switch === false) await this.e.reply('请发送「#设置抖音推送开启」以进行推送') logger.info(`\n设置成功!${UserInfoData.data.user.nickname}\n抖音号:${user_shortid}\nsec_uid${UserInfoData.data.user.sec_uid}`) } await this.renderPushList() } catch (error) { throw new Error(`设置失败,请查看日志: ${error}`) } } /** 渲染推送列表图片 */ async renderPushList(): Promise { await this.syncConfigToDatabase() const groupInfo = await this.e.bot.getGroupInfo('groupId' in this.e && this.e.groupId ? this.e.groupId : '') // 获取当前群组的所有订阅 const subscriptions = await douyinDB.getGroupSubscriptions(groupInfo.groupId) if (subscriptions.length === 0) { await this.e.reply( `当前群:${groupInfo.groupName}(${groupInfo.groupId})\n没有设置任何抖音博主推送!\n可使用「#设置抖音推送 + 抖音号」进行设置` ) return } const renderOpt: DouyinUserListData['renderOpt'] = [] for (const subscription of subscriptions) { const sec_uid = subscription.sec_uid const userInfo = await this.amagi.douyin.fetcher.fetchUserProfile({ sec_uid }) // 查找配置文件中对应的全局开关状态 const configItem = Config.pushlist.douyin?.find((item: douyinPushItem) => item.sec_uid === sec_uid) const switchStatus = configItem?.switch !== false // 默认为 true const pushTypes = configItem?.pushTypes || ['post'] renderOpt.push({ avatar_img: userInfo.data.user.avatar_larger.url_list[0], username: userInfo.data.user.nickname, short_id: userInfo.data.user.unique_id === '' ? userInfo.data.user.short_id : userInfo.data.user.unique_id, fans: this.count(userInfo.data.user.follower_count), total_favorited: this.count(userInfo.data.user.total_favorited), following_count: this.count(userInfo.data.user.following_count), switch: switchStatus, pushTypes }) } const img = await Render(this.e, 'douyin/userlist', { renderOpt, groupInfo: { groupId: groupInfo.groupId || '', groupName: groupInfo.groupName || '', groupAvatar: groupInfo.avatar || '' } }) await this.e.reply(img) } /** * 强制推送 * @param data 处理完成的推送列表 */ async forcepush(data: WillBePushList) { const currentGroupId = 'groupId' in this.e && this.e.groupId ? this.e.groupId : '' const currentBotId = this.e.selfId // 如果不是全部强制推送,需要过滤数据 if (!this.e.msg.includes('全部')) { // 获取当前群组订阅的所有抖音用户 const subscriptions = await douyinDB.getGroupSubscriptions(currentGroupId) const subscribedUids = subscriptions.map((sub) => sub.sec_uid) // 创建一个新的推送列表,只包含当前群组订阅的用户的作品 const filteredData: WillBePushList = {} for (const awemeId in data) { // 检查该作品的用户是否被当前群组订阅 if (subscribedUids.includes(data[awemeId].sec_uid)) { // 复制该作品到过滤后的列表,并将目标设置为当前群组 filteredData[awemeId] = { ...data[awemeId], targets: [ { groupId: currentGroupId, botId: currentBotId } ] } } } // 使用过滤后的数据进行推送 await this.getdata(filteredData) } else { // 全部强制推送,保持原有逻辑 await this.getdata(data) } } /** * 检查并更新备注信息 */ async checkremark() { // 读取配置文件内容 const config = Config.pushlist const updateList: { sec_uid: string }[] = [] if (Config.pushlist.douyin === null || Config.pushlist.douyin.length === 0) return true // 遍历配置文件中的用户列表,收集需要更新备注信息的用户 for (const i of Config.pushlist.douyin) { const remark = i.remark const sec_uid = i.sec_uid if (remark === undefined || remark === '') { updateList.push({ sec_uid }) } } // 如果有需要更新备注的用户,则逐个获取备注信息并更新到配置文件中 if (updateList.length > 0) { for (const i of updateList) { // 从外部数据源获取用户备注信息 const userinfo = await this.amagi.douyin.fetcher.fetchUserProfile({ sec_uid: i.sec_uid }) const remark = userinfo.data.user.nickname // 在配置文件中找到对应的用户,并更新其备注信息 const matchingItemIndex = config.douyin.findIndex((item: { sec_uid: string }) => item.sec_uid === i.sec_uid) if (matchingItemIndex !== -1) { config.douyin[matchingItemIndex].remark = remark } } // 将更新后的配置文件内容写回文件 Config.Modify('pushlist', 'douyin', config.douyin) } return false } /** * 格式化数字 */ count(num: number) { if (num > 10000) { return (num / 10000).toFixed(1) + '万' } return num.toString() } } /** * 判断标题是否有屏蔽词或屏蔽标签 * @param PushItem 推送项 * @returns 是否应该跳过推送 */ const skipDynamic = async (PushItem: DouyinPushItem): Promise => { // 直播动态不做内容过滤 if (PushItem.pushType === 'live' || 'liveStatus' in PushItem.Detail_Data) { return false } const tags: string[] = [] // 提取标签 if (PushItem.Detail_Data.text_extra) { for (const item of PushItem.Detail_Data.text_extra) { if (item.hashtag_name) { tags.push(item.hashtag_name) } } } logger.debug(`检查作品是否需要过滤:${PushItem.Detail_Data.share_url}`) const shouldFilter = await douyinDB.shouldFilter(PushItem, tags) return shouldFilter }