@luoyi-dsh/instagram-scroll 1.0.3 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,9 @@
7
7
  * 就在該貼文底下留一條評論「@主人 + 推薦原因」,並記錄至今日日報。
8
8
  * ============================================================================
9
9
  */
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
10
13
  /**
11
14
  * 正文解析與標籤分離器
12
15
  * 徹底區分貼文真正的正文內容與文末堆砌的標籤 (防止標籤農場誤導)
@@ -41,11 +44,255 @@ export function parsePostContent(caption = '') {
41
44
  isTagSpam,
42
45
  };
43
46
  }
47
+ const AIGC_REJECT_KEYWORDS = [
48
+ 'ai生成',
49
+ 'ai 生成',
50
+ 'ai影片',
51
+ 'ai 影片',
52
+ 'ai視頻',
53
+ 'ai 視頻',
54
+ 'ai動畫',
55
+ 'ai 動畫',
56
+ 'ai動漫',
57
+ 'ai 動漫',
58
+ 'ai繪圖',
59
+ 'ai 繪圖',
60
+ 'ai畫',
61
+ 'ai繪畫',
62
+ 'ai算圖',
63
+ 'ai美女',
64
+ 'ai模特',
65
+ 'ai寫作',
66
+ 'ai文章',
67
+ 'ai生成文章',
68
+ 'ai配音',
69
+ 'ai翻唱',
70
+ 'ai漫改',
71
+ 'ai短劇',
72
+ 'aigc',
73
+ 'midjourney',
74
+ 'stable diffusion',
75
+ 'stablediffusion',
76
+ 'comfyui',
77
+ 'sora',
78
+ 'runwayml',
79
+ 'runway gen',
80
+ 'kling ai',
81
+ 'klingai',
82
+ '可靈',
83
+ '即夢',
84
+ 'luma dream',
85
+ 'lumalabs',
86
+ 'pika art',
87
+ 'pika labs',
88
+ 'hailuo ai',
89
+ '海螺ai',
90
+ 'suno ai',
91
+ 'udio',
92
+ 'ai art',
93
+ 'aiart',
94
+ 'aiartist',
95
+ 'ai video',
96
+ 'aivideo',
97
+ 'ai animation',
98
+ 'aianimation',
99
+ 'ai generated',
100
+ 'ai-generated',
101
+ 'aigenerated',
102
+ 'ai girl',
103
+ 'aigirl',
104
+ 'ai artwork',
105
+ 'generated by ai',
106
+ 'made with ai',
107
+ 'created with ai',
108
+ ];
109
+ function detectAigcPost(caption = '', username = '') {
110
+ const textLower = `${caption || ''} ${username || ''}`.toLowerCase();
111
+ for (const kw of AIGC_REJECT_KEYWORDS) {
112
+ if (kw && textLower.includes(kw.toLowerCase())) {
113
+ return kw;
114
+ }
115
+ }
116
+ const aigcTagRegex = /#(aiart|aivideo|aianimation|aigenerated|midjourney|stablediffusion|sora|runwayml|klingai|pikaart|aigirl|aigc|ai繪圖|ai動畫|ai影片|ai生成)/i;
117
+ const m = textLower.match(aigcTagRegex);
118
+ if (m)
119
+ return m[0];
120
+ return null;
121
+ }
122
+ const SCROLL_CONTENT_SPECIFIC_RULE = `【推坑留言內容動態生成鐵則(嚴禁空泛說「很推薦 / 值得一看 / 乾貨滿滿」)】
123
+ 1. 你的推坑留言(@主人 後面的那句話)必須 100% 根據「這則貼文或 Reel 裡面的具體內容」動態生成!
124
+ 2. 必須點出這則貼文/Reel 裡只有看過的人才說得出來的一個具體細節、畫面亮點、某個做法、某個詞、某個反差或技術點(例如:「那個護目鏡的反光好像是用圖層混合疊出來的,邊緣卻收得很乾淨」、「他把狀態機拆成三層去接 WebSocket,那段重連處理寫得挺聰明的」)。
125
+ 3. 絕對禁止任何萬金油推薦套話!嚴禁出現:「很推薦」、「強烈推薦」、「特別推薦」、「非常推薦」、「值得一看」、「很值得看」、「推薦你看」、「乾貨滿滿」、「推推」、「大推」、「不能錯過」、「分享非常扎實」、「分析得很透徹」、「這篇關於…」、「這支影片…」等放在哪篇貼文都通用的廢話!
126
+ 4. 直接從你在貼文裡注意到的那個具體細節開口,像跟很熟的主人隨口聊起眼前看到的東西,不超過兩句話,自然收尾。`;
127
+ function resolveOwnerMemoryContext(masterUsername) {
128
+ const shared = globalThis.__DSH_INSTAGRAM_LLM__;
129
+ if (shared && typeof shared.loadOwnerMemoryInterestContext === 'function') {
130
+ try {
131
+ return shared.loadOwnerMemoryInterestContext(masterUsername);
132
+ }
133
+ catch { }
134
+ }
135
+ // 獨立運行時的備援記憶讀取器 (~/.dsh/fish-memory/session-*.json)
136
+ const empty = { hasMemories: false, memoryCount: 0, memoryLines: [], dynamicKeywords: [], promptBlock: '' };
137
+ try {
138
+ const dshHome = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || homedir(), '.dsh');
139
+ const fishDir = join(dshHome, 'fish-memory');
140
+ if (!existsSync(fishDir))
141
+ return empty;
142
+ const files = readdirSync(fishDir).filter((f) => f.endsWith('.json') && (f.startsWith('session-') || f === 'global.json' || f === 'default.json'));
143
+ if (files.length === 0)
144
+ return empty;
145
+ const nowMs = Date.now();
146
+ const items = [];
147
+ for (const file of files) {
148
+ try {
149
+ const raw = JSON.parse(readFileSync(join(fishDir, file), 'utf-8'));
150
+ for (const m of Array.isArray(raw?.memories) ? raw.memories : []) {
151
+ const text = String(m?.text || '').trim();
152
+ if (!text || text.length < 4)
153
+ continue;
154
+ const createdMs = new Date(m.at || m.createdAt || nowMs).getTime();
155
+ const ageDays = Math.max(0, (nowMs - (Number.isFinite(createdMs) ? createdMs : nowMs)) / 86400000);
156
+ const recency = Math.exp(-(Math.LN2 * ageDays) / 21);
157
+ const imp = Math.max(0, Math.min(1, Number(m.importance ?? 0.5)));
158
+ items.push({
159
+ text,
160
+ tags: Array.isArray(m.tags) ? m.tags : [],
161
+ related: Array.isArray(m.related) ? m.related : [],
162
+ score: 0.5 * recency + 0.35 * imp + (m.category === 'stable_profile' ? 0.25 : 0),
163
+ });
164
+ }
165
+ }
166
+ catch { }
167
+ }
168
+ if (items.length === 0)
169
+ return empty;
170
+ items.sort((a, b) => b.score - a.score);
171
+ const memoryLines = items.slice(0, 10).map((m) => `- ${m.text}`);
172
+ const kwSet = new Set();
173
+ for (const m of items.slice(0, 10)) {
174
+ const eng = m.text.match(/\b[a-zA-Z][a-zA-Z0-9+#.-]{2,18}\b/g) || [];
175
+ for (const w of eng) {
176
+ if (!/^(http|https|www|com|org|net|the|and|for|with|from)$/i.test(w))
177
+ kwSet.add(w.toLowerCase());
178
+ }
179
+ }
180
+ const dynamicKeywords = Array.from(kwSet).slice(0, 5);
181
+ const promptBlock = `【與主人的過往對話記憶脈絡 (dsh-fish-memory,用於動態選擇主人真正可能喜歡的內容)】:\n${memoryLines.join('\n')}\n- 【記憶驅動選片準則】:請結合上方「與用戶對話的記憶」與主人的興趣偏好,動態判斷這則貼文或 Reel 是否契合主人近期關注的事物、聊過的話題、技術方向或審美喜好。\n- 【隱私保護紅線】:上方對話記憶僅供你在心裡判斷選片口味,撰寫公開留言時必須 100% 聚焦於「這則貼文/Reel 本身的內容細節」,絕對不可把主人的私人行程、作息時間或隱私對話直接寫在公開留言區!`;
182
+ return {
183
+ hasMemories: true,
184
+ memoryCount: memoryLines.length,
185
+ memoryLines,
186
+ dynamicKeywords,
187
+ promptBlock,
188
+ };
189
+ }
190
+ catch {
191
+ return empty;
192
+ }
193
+ }
194
+ function sanitizeRecommendText(rawComment, targetUser) {
195
+ const shared = globalThis.__DSH_INSTAGRAM_LLM__;
196
+ if (shared && typeof shared.sanitizeRecommendationComment === 'function') {
197
+ return shared.sanitizeRecommendationComment(rawComment, targetUser);
198
+ }
199
+ if (!rawComment)
200
+ return '';
201
+ let text = String(rawComment)
202
+ .trim()
203
+ .replace(/^["'「『“”]+|["'」』“”]+$/g, '')
204
+ .replace(/^\[?\d{1,3}\s*分\]?[::、,,\s-]*/g, '')
205
+ .replace(/^@[\p{L}\p{N}._]+[\s,,::、]+/u, '')
206
+ .replace(/^這篇關於\s*#?[^\s::,,]+?\s*的內容(很|非常|超)?值得一看[::,,\s]*/g, '')
207
+ .replace(/[,,。!!;;\s]*(真的|非常|特別|強烈|真心|超|很)*(很推薦給?你看|推薦給?你看|推薦給?你|很推薦|強烈推薦|特別推薦|值得一看|很值得看|乾貨滿滿|不能錯過|分享非常扎實|分析得很透徹|推推|大推|特地\s*@\s*你存起來)[!!~~。]*$/g, '')
208
+ .trim();
209
+ if (!text || text.length < 5)
210
+ return '';
211
+ if (/^(很推薦|推薦你看|值得一看|分享給你|這篇很棒|這篇不錯)[!!。~~\s]*$/.test(text))
212
+ return '';
213
+ return text;
214
+ }
215
+ function parseRecommendLlmOutput(llmReply, minScore = 80, targetUser) {
216
+ const shared = globalThis.__DSH_INSTAGRAM_LLM__;
217
+ if (shared && typeof shared.parseRecommendationLlmOutput === 'function') {
218
+ return shared.parseRecommendationLlmOutput(llmReply, minScore, targetUser);
219
+ }
220
+ const raw = String(llmReply || '').trim();
221
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
222
+ if (jsonMatch) {
223
+ try {
224
+ const parsed = JSON.parse(jsonMatch[0]);
225
+ const score = Number(parsed.score ?? (parsed.isWorthy || parsed.recommend ? 85 : 30));
226
+ const isApprove = Boolean(parsed.isWorthy ?? parsed.recommend) && score >= minScore;
227
+ const rawHook = String(parsed.comment || parsed.hookPhrase || parsed.reason || '').trim();
228
+ const commentBody = isApprove ? sanitizeRecommendText(rawHook, targetUser) : '';
229
+ if (isApprove && commentBody) {
230
+ return {
231
+ isRecommend: true,
232
+ score,
233
+ commentBody,
234
+ reason: String(parsed.reason || parsed.summary || commentBody),
235
+ rejectionReason: '',
236
+ };
237
+ }
238
+ return {
239
+ isRecommend: false,
240
+ score,
241
+ commentBody: '',
242
+ reason: '',
243
+ rejectionReason: String(parsed.rejectionReason || '未達推薦門檻或無具體貼文評論'),
244
+ };
245
+ }
246
+ catch { }
247
+ }
248
+ const scoreMatch = raw.match(/(?:^|\b)(100|[1-9]\d)\s*分(?:[::、,,\s-]*([\s\S]+))?/);
249
+ if (scoreMatch) {
250
+ const score = Number(scoreMatch[1]);
251
+ const rest = String(scoreMatch[2] || raw.replace(scoreMatch[0], '')).trim();
252
+ const commentBody = score >= minScore ? sanitizeRecommendText(rest, targetUser) : '';
253
+ if (score >= minScore && commentBody) {
254
+ return {
255
+ isRecommend: true,
256
+ score,
257
+ commentBody,
258
+ reason: `符合主人記憶偏好 (${score}分):${commentBody}`,
259
+ rejectionReason: '',
260
+ };
261
+ }
262
+ return {
263
+ isRecommend: false,
264
+ score,
265
+ commentBody: '',
266
+ reason: '',
267
+ rejectionReason: `評分 (${score}分) 未達門檻 (${minScore}分)`,
268
+ };
269
+ }
270
+ return {
271
+ isRecommend: false,
272
+ score: 0,
273
+ commentBody: '',
274
+ reason: '',
275
+ rejectionReason: 'LLM 未給出高分與針對貼文內容的具體評論',
276
+ };
277
+ }
44
278
  /**
45
- * 調用宿主 LLM 或環境變數中的 DeepSeek / OpenAI 服務進行深度語義內容審查
279
+ * 調用共享外部插件訂閱額度 (dsh-plugin-subscriptions / __DSH_INSTAGRAM_LLM__)、宿主 LLM 或自訂 API 進行深度語義內容審查
46
280
  */
47
- async function callLlmInference(ctx, systemPrompt, userPrompt) {
48
- // 1. 優先嘗試 DSH 宿主注入的 LLM 服務
281
+ async function callLlmInference(ctx, systemPrompt, userPrompt, imageUrls = []) {
282
+ // 0. 優先使用 dsh-instagram-plugin 共享的統一 LLM 引擎 (支援讀取 dsh-plugin-subscriptions 外部額度與多模態視覺圖片)
283
+ const sharedIgLlm = globalThis.__DSH_INSTAGRAM_LLM__;
284
+ if (sharedIgLlm && typeof sharedIgLlm.callLlmForInstagram === 'function') {
285
+ try {
286
+ const sharedCfg = globalThis.__DSH_INSTAGRAM_PLUGIN_CONFIG__ || {};
287
+ const res = await sharedIgLlm.callLlmForInstagram(ctx, sharedCfg, systemPrompt, userPrompt, console.log, imageUrls);
288
+ if (res && res.trim())
289
+ return res.trim();
290
+ }
291
+ catch (e) {
292
+ console.warn(`[ScrollDuty LLM] 共享訂閱 LLM 調用異常: ${e.message}`);
293
+ }
294
+ }
295
+ // 1. 嘗試 DSH 宿主注入的 LLM 服務
49
296
  const llm = (typeof ctx?.get === 'function' ? ctx.get('llm') : null) || ctx?.llm;
50
297
  if (llm) {
51
298
  try {
@@ -55,7 +302,7 @@ async function callLlmInference(ctx, systemPrompt, userPrompt) {
55
302
  { role: 'system', content: systemPrompt },
56
303
  { role: 'user', content: userPrompt },
57
304
  ],
58
- temperature: 0.3,
305
+ temperature: 0.5,
59
306
  });
60
307
  const reply = res?.content || res?.text || (typeof res === 'string' ? res : '');
61
308
  if (reply)
@@ -88,8 +335,7 @@ async function callLlmInference(ctx, systemPrompt, userPrompt) {
88
335
  { role: 'system', content: systemPrompt },
89
336
  { role: 'user', content: userPrompt },
90
337
  ],
91
- temperature: 0.3,
92
- response_format: { type: 'json_object' },
338
+ temperature: 0.5,
93
339
  }),
94
340
  });
95
341
  if (resp.ok) {
@@ -105,22 +351,65 @@ async function callLlmInference(ctx, systemPrompt, userPrompt) {
105
351
  }
106
352
  /**
107
353
  * 嚴密的高精度內容檢驗引擎 (Content Inspection Pipeline)
108
- * 解決痛點:「不要隨便推薦、要依據用戶喜歡內容、@ 前要檢查貼文內容、不要只看 tag」
354
+ * 解決痛點:
355
+ * 1. 根據與用戶對話的記憶 (dsh-fish-memory) 動態選擇用戶可能喜歡的內容才做推薦
356
+ * 2. 推薦時的 @+文字 必須根據該貼文或 Reel 的具體內容動態生成,嚴禁空泛說「很推薦...」或使用罐頭模板
109
357
  */
110
- export async function inspectAndEvaluatePost(ctx, post, config) {
111
- const master = (config.masterUsername || '').replace(/^@/, '').trim();
112
- const parsed = parsePostContent(post.caption || '');
358
+ export async function inspectAndEvaluatePost(ctx, post, config, mcp) {
359
+ const mcpSvc = globalThis.__DSH_INSTAGRAM_MCP__;
360
+ const master = (mcpSvc?.config?.masterUsername || config.masterUsername || '').replace(/^@/, '').trim();
361
+ const postPk = String(post.pk || post.id || '').split('_')[0].trim();
362
+ // 若貼文文字較短或為 Reel 短片,嘗試拉取完整上下文(字幕、視覺描述、音訊、圖片)以供 AI 讀取真實內容
363
+ let rawCaption = String(post.caption || '');
364
+ let postTitle = String(post.title || '');
365
+ let a11yCaption = String(post.accessibility_caption || '');
366
+ let videoSubtitles = String(post.video_subtitles || '');
367
+ let audioInfo = String(post.audio_info || '');
368
+ let imageUrls = Array.isArray(post.image_urls) ? [...post.image_urls] : [];
369
+ if (post.thumbnail_url && !imageUrls.includes(post.thumbnail_url)) {
370
+ imageUrls.unshift(post.thumbnail_url);
371
+ }
372
+ if (mcp && postPk && (rawCaption.length < 60 || imageUrls.length === 0 || post.media_type === 2 || post.product_type === 'clips')) {
373
+ try {
374
+ const detailCtx = await mcp.igGetPostContext(postPk, 8);
375
+ const dp = detailCtx?.media || detailCtx?.post;
376
+ if (dp) {
377
+ if (dp.caption && String(dp.caption).length > rawCaption.length)
378
+ rawCaption = String(dp.caption);
379
+ if (dp.title)
380
+ postTitle = String(dp.title);
381
+ if (dp.accessibility_caption)
382
+ a11yCaption = String(dp.accessibility_caption);
383
+ if (dp.video_subtitles)
384
+ videoSubtitles = String(dp.video_subtitles);
385
+ if (dp.audio_info)
386
+ audioInfo = String(dp.audio_info);
387
+ if (Array.isArray(dp.image_urls) && dp.image_urls.length > 0) {
388
+ imageUrls = dp.image_urls;
389
+ }
390
+ else if (dp.thumbnail_url && !imageUrls.includes(dp.thumbnail_url)) {
391
+ imageUrls.push(dp.thumbnail_url);
392
+ }
393
+ }
394
+ }
395
+ catch { }
396
+ }
397
+ const parsed = parsePostContent(rawCaption);
113
398
  const minScore = config.minScore || 80;
114
399
  const minLength = config.minCaptionLength || 30;
115
400
  const userInterests = config.userInterests ||
116
- '專注於前沿 AI 技術、開源軟體、全端與前端架構、實用開發工具、優質 UI/UX 設計技巧或實用技術深度解析;喜愛實踐乾貨與原創見解。';
117
- // === 第 1 關:正文實質內容字數檢驗 (拒絕無內文或純圖廢文) ===
118
- if (parsed.cleanLength < minLength) {
401
+ '專注於真實人類原創手繪藝術 (插畫、Kemono、角色設計)、前沿軟體技術、開源專案、實用開發工具與原創見解。';
402
+ // 載入與主人的對話記憶脈絡
403
+ const ownerMemory = resolveOwnerMemoryContext(master);
404
+ // === 第 1 關:實質內容檢驗 (若既無正文、無字幕/視覺描述,且無圖片視覺可供多模態審閱,則拒絕) ===
405
+ const combinedTextLength = parsed.cleanLength + a11yCaption.length + videoSubtitles.length + postTitle.length;
406
+ const hasVisualMedia = imageUrls.length > 0;
407
+ if (combinedTextLength < Math.min(minLength, 12) && !hasVisualMedia) {
119
408
  return {
120
409
  isWorthy: false,
121
410
  score: 15,
122
411
  reason: '',
123
- rejectionReason: `正文有效字數不足 (${parsed.cleanLength} 字 < 門檻 ${minLength} 字),資訊量過少無實質乾貨內容。`,
412
+ rejectionReason: `貼文有效資訊不足 (${combinedTextLength} 字) 且無視覺畫面可供審閱。`,
124
413
  commentText: '',
125
414
  reviewMethod: 'heuristic',
126
415
  };
@@ -136,6 +425,18 @@ export async function inspectAndEvaluatePost(ctx, post, config) {
136
425
  reviewMethod: 'heuristic',
137
426
  };
138
427
  }
428
+ // === 第 2.5 關:嚴格攔截 AI 生成的影片、動畫、繪圖或農場文章 (AIGC Filter) ===
429
+ const aigcHit = detectAigcPost(parsed.rawCaption, post.user?.username || '');
430
+ if (aigcHit) {
431
+ return {
432
+ isWorthy: false,
433
+ score: 5,
434
+ reason: '',
435
+ rejectionReason: `判定為 AI 生成的影片/動畫/繪圖或文章 (命中 AIGC 特徵詞 [${aigcHit}]),不予推薦。`,
436
+ commentText: '',
437
+ reviewMethod: 'heuristic',
438
+ };
439
+ }
139
440
  // === 第 3 關:負面詞與廣告過濾 (Negative Keywords / Spam Filter) ===
140
441
  const negativeWords = config.negativeKeywords || [];
141
442
  const rawLower = parsed.rawCaption.toLowerCase();
@@ -152,146 +453,120 @@ export async function inspectAndEvaluatePost(ctx, post, config) {
152
453
  };
153
454
  }
154
455
  }
155
- // === 第 4 關:正文實質主題核驗 (不能只看文末 tag) ===
156
- const positiveWords = config.positiveKeywords || [];
157
- const cleanLower = parsed.cleanBody.toLowerCase();
456
+ // === 第 4 關:計算關鍵詞命中輔助資訊 (融合設定關鍵字 + 與用戶對話記憶中的動態興趣詞) ===
457
+ const positiveWords = Array.from(new Set([
458
+ ...(config.positiveKeywords || []),
459
+ ...(config.fuzzyKeywords || []),
460
+ ...(config.interestTags || []),
461
+ ...(ownerMemory.dynamicKeywords || []),
462
+ ]));
463
+ const searchableLower = `${parsed.cleanBody} ${postTitle} ${a11yCaption} ${videoSubtitles}`.toLowerCase();
158
464
  const hitBodyKeywords = [];
159
465
  for (const kw of positiveWords) {
160
466
  const cleanKw = kw.toLowerCase().trim();
161
- if (cleanKw && cleanLower.includes(cleanKw)) {
467
+ if (cleanKw && searchableLower.includes(cleanKw)) {
162
468
  hitBodyKeywords.push(cleanKw);
163
469
  }
164
470
  }
165
- // 若設定要求「正文必須命中用戶偏好」,且正文未命中任何關鍵字,直接退回
166
- if (config.requireBodyMatch && hitBodyKeywords.length === 0) {
471
+ // 若無對話記憶、無視覺圖片且開啟強制字面命中時才做前置攔截;若有對話記憶或圖片,交由第 5 關 LLM 結合記憶動態判斷
472
+ if (config.requireBodyMatch &&
473
+ hitBodyKeywords.length === 0 &&
474
+ !ownerMemory.hasMemories &&
475
+ !hasVisualMedia &&
476
+ parsed.cleanLength < minLength) {
167
477
  return {
168
478
  isWorthy: false,
169
479
  score: 30,
170
480
  reason: '',
171
- rejectionReason: `正文實質內容未提及主人偏好領域關鍵字 (僅文末標籤包含相關字詞,判定為蹭熱度)。`,
481
+ rejectionReason: `內文未提及主人偏好領域關鍵字且字數不足。`,
172
482
  commentText: '',
173
483
  reviewMethod: 'heuristic',
174
484
  };
175
485
  }
176
- // === 第 5 關:LLM 深度語義質檢 (如果環境具備 LLM 能力) ===
177
- const systemPrompt = `你是一位高品味、眼光極為嚴格的私人內容審查特助。
178
- 主人的核心原則是:「絕對不要隨便推薦、必須切合主人喜歡的內容,在留言 @ 主人之前必須深入檢查正文實質內容,嚴禁只看文末的 tag 就隨意推薦」。
179
- 主人的內容偏好定義如下:
180
- """
181
- ${userInterests}
182
- """
183
-
184
- 任務要求:
185
- 1. 請審查貼文正文是否深度探討主人喜歡的主題,還是只是空泛的標籤農場或蹭流量。
186
- 2. 評估內容是否具備前沿實踐、技術見解、架構乾貨或優質教學價值。
187
- 3. 若內容確實極為優秀且深度符合主人喜好,請給出大於等於 ${minScore} 的高分,並撰寫簡短真誠、針對此篇貼文具體乾貨的一句話推坑留言 (不要千篇一律套話)。
188
- 4. 若品質普通、偏題或僅表面蹭熱度,給予低於 ${minScore} 的分數並給出 rejectionReason。
189
-
190
- 請嚴格輸出 JSON 格式:
191
- {
192
- "isWorthy": boolean,
193
- "score": number, // 0-100
194
- "summary": "貼文真正主旨簡介 (30字內)",
195
- "rejectionReason": "未通過的原因 (若不合格)",
196
- "reason": "推薦給主人的實質價值說明 (若合格)",
197
- "hookPhrase": "針對本篇內容亮點量身打造的推坑一句話 (若合格,25字內,真誠具體,嚴禁空洞廢話)"
198
- }`;
199
- const userPrompt = `請審查以下 Instagram 貼文:
200
- - 作者:@${post.user?.username || 'unknown'}
201
- - 互動數據:${post.like_count || 0} 讚, ${post.comment_count || 0} 則留言
202
- - 貼文真正正文內容 (已剔除標籤):
203
- """
204
- ${parsed.cleanBody.slice(0, 800)}
205
- """
206
- - 文末標籤清單:#${parsed.hashtags.join(', #')}
207
- - 正文命中之領域詞:${hitBodyKeywords.join(', ') || '無'}
208
-
209
- 請依據審查原則進行嚴格評估並以 JSON 返回。`;
210
- const llmResponse = await callLlmInference(ctx, systemPrompt, userPrompt);
486
+ // === 第 5 關:LLM 深度語義與記憶契合度質檢 (唯一推薦決策來源,嚴禁使用罐頭 Heuristic 留言) ===
487
+ const sharedCompanion = globalThis.__DSH_INSTAGRAM_LLM__;
488
+ const moodBlock = typeof sharedCompanion?.buildFeiyuMoodPromptBlock === 'function'
489
+ ? sharedCompanion.buildFeiyuMoodPromptBlock(true)
490
+ : '';
491
+ const antiAiRule = sharedCompanion?.ANTI_AI_FLAVOR_PRIMARY_RULE ||
492
+ '【第一條件(最高優先級鐵則):推坑留言絕對不要 AI 味,嚴禁客服腔、總結腔、排比句或機械式吹捧,要像真人朋友隨手分享一樣自然口語】';
493
+ const personaText = config.personaPrompt ||
494
+ '你是一位眼光敏銳、說話自然的夥伴。請根據與主人的對話記憶與主人的偏好,判斷這則貼文是否值得推坑,並針對貼文裡的具體細節寫一句自然的話。';
495
+ const systemPrompt = `${antiAiRule}
496
+
497
+ ${SCROLL_CONTENT_SPECIFIC_RULE}
498
+
499
+ 【角色人設與說話方式】:
500
+ ${personaText}
501
+ ${moodBlock ? `\n${moodBlock}` : ''}
502
+ ${ownerMemory.promptBlock ? `\n${ownerMemory.promptBlock}` : ''}
503
+
504
+ 【主人長期內容偏好】:
505
+ ${userInterests}
506
+
507
+ 【審查與推坑留言鐵律】:
508
+ 1. 【最高鐵律】嚴格排除任何「AI 生成的影片、AI 動畫、AI 繪圖/算圖、AI 配音或 AI 生成的農場文章」(例如 Sora、Runway、Midjourney、Stable Diffusion、Kling、可靈、即夢、AIGC 等)。只要懷疑是 AI 算圖或 AI 影片/文章,無條件不推薦!
509
+ 2. 【根據與用戶對話的記憶動態選片】請對照上方「與主人的過往對話記憶脈絡」與「主人長期內容偏好」,動態判斷主人看到這則貼文/Reel 會不會真心喜歡、想收藏或想討論。若只是普通流量文或主人不會感興趣,請給低於 ${minScore} 分(設 isWorthy: false)。
510
+ 3. 【推坑文字必須緊扣該貼文的具體內容】若決定推薦(分數 >= ${minScore}),你寫給主人的那句話(hookPhrase)必須直接講這則貼文/Reel 裡的某個具體細節、某個畫面呈現、某句話或某個做法(只有看過這則貼文的人才說得出來的內容),絕對禁止說「很推薦」、「強烈推薦」、「值得一看」、「乾貨滿滿」、「推推」、「這篇關於…的內容分享非常扎實」等空泛套話!`;
511
+ const extraLines = [];
512
+ if (postTitle)
513
+ extraLines.push(`- 標題:${postTitle}`);
514
+ if (a11yCaption)
515
+ extraLines.push(`- 畫面視覺描述 (Accessibility):${a11yCaption}`);
516
+ if (videoSubtitles)
517
+ extraLines.push(`- 影片字幕內容:${videoSubtitles.slice(0, 500)}`);
518
+ if (audioInfo)
519
+ extraLines.push(`- 音訊資訊:${audioInfo}`);
520
+ const userPrompt = `請審查以下 Instagram ${post.media_type === 2 || post.product_type === 'clips' ? 'Reel 短片' : '貼文'}(若有附圖請一併檢視畫面細節):
521
+ - 作者:@${post.user?.username || 'unknown'} (${post.user?.full_name || ''})
522
+ - 互動數據:${post.like_count || 0} 讚, ${post.comment_count || 0} 則留言
523
+ ${extraLines.length > 0 ? extraLines.join('\n') + '\n' : ''}- 貼文正文內容 (已剔除文末標籤):
524
+ """
525
+ ${parsed.cleanBody.slice(0, 900) || '(此貼文以畫面/視覺呈現為主,請參考畫面描述與附圖)'}
526
+ """
527
+ - 文末標籤清單:#${parsed.hashtags.join(', #')}
528
+ - 命中之興趣/記憶關鍵詞:${hitBodyKeywords.join(', ') || '由語義與記憶脈絡動態判斷'}
529
+
530
+ 請輸出 JSON 格式:
531
+ {
532
+ "isWorthy": boolean,
533
+ "score": number, // 0-100,達 ${minScore} 分以上才推薦
534
+ "rejectionReason": "未通過的原因 (若不合格)",
535
+ "reason": "為什麼根據主人的對話記憶與喜好覺得主人會喜歡這篇 (內部日誌用)",
536
+ "hookPhrase": "直接對主人說的那一句推坑留言(必須點出這則貼文/畫面裡的具體細節,不要加 @用戶名,嚴禁出現『很推薦』『值得一看』『乾貨滿滿』『推推』等套話!)"
537
+ }
538
+ (或依你的人設直接回覆:「85分 <針對這則貼文具體內容細節的那一句推坑>」;若不合格則回覆「不推薦」)`;
539
+ const llmResponse = await callLlmInference(ctx, systemPrompt, userPrompt, imageUrls.slice(0, 2));
211
540
  if (llmResponse) {
212
- try {
213
- const cleanedJson = llmResponse.replace(/^```json\s*/i, '').replace(/\s*```$/i, '').trim();
214
- const parsedLlm = JSON.parse(cleanedJson);
215
- const score = Number(parsedLlm.score || 0);
216
- const isWorthy = Boolean(parsedLlm.isWorthy) && score >= minScore;
217
- if (isWorthy) {
218
- const hook = String(parsedLlm.hookPhrase || parsedLlm.reason || '這篇分析非常到位,強烈推薦!').trim();
219
- const commentText = master ? `@${master} ${hook}` : hook;
220
- return {
221
- isWorthy: true,
222
- score,
223
- reason: String(parsedLlm.reason || parsedLlm.summary || '內容深度契合主人關注領域且具備乾貨價值。'),
224
- commentText,
225
- reviewMethod: 'llm',
226
- };
227
- }
228
- else {
229
- return {
230
- isWorthy: false,
231
- score,
232
- reason: '',
233
- rejectionReason: String(parsedLlm.rejectionReason || '內容深度未達主人品味標準或主題不符。'),
234
- commentText: '',
235
- reviewMethod: 'llm',
236
- };
237
- }
238
- }
239
- catch (e) {
240
- console.warn(`[ScrollDuty] LLM 結果 JSON 解析異常: ${e.message}`);
541
+ const parsedEval = parseRecommendLlmOutput(llmResponse, minScore, master);
542
+ if (parsedEval.isRecommend && parsedEval.commentBody) {
543
+ const commentText = master ? `@${master} ${parsedEval.commentBody}` : parsedEval.commentBody;
544
+ return {
545
+ isWorthy: true,
546
+ score: parsedEval.score,
547
+ reason: parsedEval.reason,
548
+ commentText,
549
+ reviewMethod: 'llm',
550
+ };
241
551
  }
242
- }
243
- // === 備援:啟發式嚴密內容質檢 (Heuristic Rule Engine) ===
244
- // 當宿主無 LLM 或 LLM 調用異常時,依然嚴守品質底線,絕不隨便推薦!
245
- let heuristicScore = 40; // 底分
246
- // 正文字數深度加分 (有較長詳細說明通常具備更高乾貨密度)
247
- if (parsed.cleanLength > 300)
248
- heuristicScore += 18;
249
- else if (parsed.cleanLength > 150)
250
- heuristicScore += 12;
251
- else if (parsed.cleanLength > 60)
252
- heuristicScore += 6;
253
- // 正文實質命中關鍵詞加分
254
- heuristicScore += Math.min(25, hitBodyKeywords.length * 8);
255
- // 乾貨排版特徵檢查 (具備條列、步驟或結構化排版)
256
- if (/[0-9][.、]|👉|✅|🔹|💡|📌|\n- |\n\* /.test(parsed.cleanBody)) {
257
- heuristicScore += 10;
258
- }
259
- // 社群互動數據適度輔助加分 (最多 12 分,避免高讚垃圾貼文)
260
- const likes = post.like_count || 0;
261
- const comments = post.comment_count || 0;
262
- if (likes > 300)
263
- heuristicScore += 6;
264
- if (comments > 15)
265
- heuristicScore += 6;
266
- heuristicScore = Math.min(96, Math.max(10, heuristicScore));
267
- const isHeuristicWorthy = heuristicScore >= minScore;
268
- if (isHeuristicWorthy) {
269
- const reason = `正文實質深度探討 [${hitBodyKeywords.slice(0, 3).join(', ')}],文字詳實完整 (${parsed.cleanLength} 字),結構條理分明。`;
270
- const hookPhrases = [
271
- `這篇關於 ${hitBodyKeywords[0] || '該主題'} 的內容分享非常扎實,特別推薦你看!`,
272
- `發現這篇乾貨對你的方向很有啟發,特地 @ 你存起來!`,
273
- `這篇思路與案例分析得很透徹,內容很硬核,推推!`,
274
- ];
275
- const chosenHook = hookPhrases[Math.floor(Math.random() * hookPhrases.length)];
276
- const commentText = master ? `@${master} ${chosenHook}` : `這篇乾貨非常精彩,推薦關注!`;
277
- return {
278
- isWorthy: true,
279
- score: heuristicScore,
280
- reason,
281
- commentText,
282
- reviewMethod: 'heuristic',
283
- };
284
- }
285
- else {
286
552
  return {
287
553
  isWorthy: false,
288
- score: heuristicScore,
554
+ score: parsedEval.score || 35,
289
555
  reason: '',
290
- rejectionReason: `內容乾貨密度評分 (${heuristicScore}) 未達嚴選門檻 (${minScore})。`,
556
+ rejectionReason: parsedEval.rejectionReason || '經 AI 結合主人對話記憶與貼文內容審查,未達推薦標準。',
291
557
  commentText: '',
292
- reviewMethod: 'heuristic',
558
+ reviewMethod: 'llm',
293
559
  };
294
560
  }
561
+ // 當 LLM 無法連線或未回應時,絕不使用罐頭模板亂發「很推薦 / 乾貨滿滿 / 推推」留言!
562
+ return {
563
+ isWorthy: false,
564
+ score: 0,
565
+ reason: '',
566
+ rejectionReason: 'AI 模型暫時無法連線生成針對該貼文內容的具體推坑留言,為避免發送罐頭套話已跳過推薦。',
567
+ commentText: '',
568
+ reviewMethod: 'llm',
569
+ };
295
570
  }
296
571
  export async function executeScrollRound(ctx, config, mcp, ledger, log = console.log) {
297
572
  // 防風控冷卻保護檢查
@@ -310,9 +585,17 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
310
585
  };
311
586
  }
312
587
  log('[ScrollDuty] 🚀 開始替主人刷一輪 Instagram (啟動正文內容嚴格審查模式)...');
313
- const master = (config.masterUsername || '').replace(/^@/, '').trim();
588
+ const mcpSvc = globalThis.__DSH_INSTAGRAM_MCP__;
589
+ const master = (mcpSvc?.config?.masterUsername || config.masterUsername || '').replace(/^@/, '').trim();
314
590
  if (!master) {
315
- log('[ScrollDuty] ⚠️ 尚未配置主人帳號 (masterUsername),將僅進行瀏覽審核而不留言 @主人。');
591
+ log('[ScrollDuty] ⚠️ 尚未在 MCP 配置主人帳號 (masterUsername),將僅進行瀏覽審核而不留言 @主人。');
592
+ }
593
+ // 載入與主人的過往對話記憶,用於動態擴充檢索興趣詞與選片判斷
594
+ const ownerMemory = resolveOwnerMemoryContext(master);
595
+ if (ownerMemory.hasMemories) {
596
+ log(`[ScrollDuty] 🧠 已讀取與主人 (@${master || 'master'}) 的對話記憶 ${ownerMemory.memoryCount} 條${ownerMemory.dynamicKeywords.length > 0
597
+ ? `,動態記憶興趣詞: [${ownerMemory.dynamicKeywords.slice(0, 4).join(', ')}]`
598
+ : ''}`);
316
599
  }
317
600
  const batchSize = Math.max(5, config.batchSize || 15);
318
601
  const maxPerRun = config.maxRecommendsPerRun || 1;
@@ -340,15 +623,53 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
340
623
  };
341
624
  }
342
625
  }
343
- // 2. 隨機選取一個主人感興趣的標籤進行探索抓取 (Explore Tag)
626
+ // 2A. 模糊搜尋探索 (Fuzzy Search) —— 結合設定關鍵字與從主人對話記憶動態提取的興趣詞
627
+ const useFuzzy = config.fuzzySearchEnabled !== false;
628
+ const baseFuzzyWords = config.fuzzyKeywords && config.fuzzyKeywords.length > 0
629
+ ? config.fuzzyKeywords
630
+ : config.interestTags || [];
631
+ const fuzzyWords = Array.from(new Set([...(ownerMemory.dynamicKeywords || []).slice(0, 3), ...baseFuzzyWords]));
632
+ if (useFuzzy && fuzzyWords.length > 0) {
633
+ const randomKeyword = fuzzyWords[Math.floor(Math.random() * fuzzyWords.length)].replace(/^#/, '').trim();
634
+ if (randomKeyword) {
635
+ try {
636
+ log(`[ScrollDuty] 🔎 正在使用模糊搜尋探索關鍵字 "${randomKeyword}"...`);
637
+ const fuzzyRes = typeof mcp.igFuzzySearchPosts === 'function'
638
+ ? await mcp.igFuzzySearchPosts(randomKeyword, 10)
639
+ : await mcp.callTool('ig_fuzzy_search_posts', { query: randomKeyword, limit: 10 }, 75000);
640
+ if (fuzzyRes?.ok && Array.isArray(fuzzyRes.posts)) {
641
+ for (const p of fuzzyRes.posts) {
642
+ const pk = String(p.pk || p.id || '');
643
+ if (pk && !candidatePosts.some((cp) => String(cp.pk || cp.id || '') === pk)) {
644
+ candidatePosts.push(p);
645
+ }
646
+ }
647
+ log(`[ScrollDuty] 從模糊搜尋 "${randomKeyword}" 獲取到 ${fuzzyRes.posts.length} 則貼文。`);
648
+ }
649
+ }
650
+ catch (e) {
651
+ log(`[ScrollDuty] 模糊搜尋探索異常: ${e.message}`);
652
+ if (/feedback|action block|checkpoint|rate limit/i.test(e.message)) {
653
+ ledger.triggerCooldown(config.cooldownOnBlockMinutes || 30, e.message);
654
+ }
655
+ }
656
+ }
657
+ }
658
+ // 2B. 主題標籤探索 (Explore Tag) —— 保留功能,由 tagSearchEnabled 按鈕控制開關 (預設關閉)
659
+ const useTags = config.tagSearchEnabled === true;
344
660
  const tags = config.interestTags || [];
345
- if (tags.length > 0) {
661
+ if (useTags && tags.length > 0) {
346
662
  const randomTag = tags[Math.floor(Math.random() * tags.length)].replace(/^#/, '').trim();
347
663
  try {
348
- log(`[ScrollDuty] 正在探索主題標籤 #${randomTag}...`);
664
+ log(`[ScrollDuty] 🏷️ 正在探索主題標籤 #${randomTag}...`);
349
665
  const tagRes = await mcp.igSearchHashtag(randomTag, 10);
350
666
  if (tagRes?.ok && Array.isArray(tagRes.posts)) {
351
- candidatePosts.push(...tagRes.posts);
667
+ for (const p of tagRes.posts) {
668
+ const pk = String(p.pk || p.id || '');
669
+ if (pk && !candidatePosts.some((cp) => String(cp.pk || cp.id || '') === pk)) {
670
+ candidatePosts.push(p);
671
+ }
672
+ }
352
673
  log(`[ScrollDuty] 從 #${randomTag} 獲取到 ${tagRes.posts.length} 則主題貼文。`);
353
674
  }
354
675
  }
@@ -359,6 +680,23 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
359
680
  }
360
681
  }
361
682
  }
683
+ // 3. 抓取連續短片探索流 (Explore Reels Feed) —— 豐富動態滾動來源,避免時間軸枯竭
684
+ try {
685
+ log(`[ScrollDuty] 正在探索熱門短影音 (Reels Feed, 抓取上限: 10)...`);
686
+ const reelsRes = await mcp.igGetReelsFeed(10);
687
+ if (reelsRes?.ok && Array.isArray(reelsRes.reels)) {
688
+ for (const r of reelsRes.reels) {
689
+ const pk = String(r.pk || r.id || '');
690
+ if (pk && !candidatePosts.some((cp) => String(cp.pk || cp.id || '') === pk)) {
691
+ candidatePosts.push(r);
692
+ }
693
+ }
694
+ log(`[ScrollDuty] 從 Reels 探索流獲取到 ${reelsRes.reels.length} 則連續短片。`);
695
+ }
696
+ }
697
+ catch (e) {
698
+ log(`[ScrollDuty] 獲取 Reels 探索流異常: ${e.message}`);
699
+ }
362
700
  let totalScanned = 0;
363
701
  let worthyCount = 0;
364
702
  let recommendedCount = 0;
@@ -369,32 +707,56 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
369
707
  log(`[ScrollDuty] 達到本輪推坑上限 (${maxPerRun} 則),結束本輪推薦。`);
370
708
  break;
371
709
  }
372
- const postPk = String(post.pk || post.id || '');
710
+ const postPk = String(post.pk || post.id || '').split('_')[0].trim();
373
711
  if (!postPk)
374
712
  continue;
375
713
  // 跳過已看過的貼文
376
- if (ledger.hasSeen(postPk)) {
714
+ if (ledger.hasSeen(post) || ledger.hasEverSent(post)) {
377
715
  continue;
378
716
  }
717
+ ledger.markSeen(post, false);
379
718
  // 模擬人類瀏覽閱讀貼文微延遲
380
719
  const minDelay = config.minPostReadDelaySeconds || 3;
381
720
  const maxDelay = Math.max(minDelay, config.maxPostReadDelaySeconds || 7);
382
721
  const readWaitMs = Math.floor((minDelay + Math.random() * (maxDelay - minDelay)) * 1000);
383
722
  await new Promise((r) => setTimeout(r, readWaitMs));
384
723
  totalScanned++;
385
- const evalRes = await inspectAndEvaluatePost(ctx, post, config);
724
+ const evalRes = await inspectAndEvaluatePost(ctx, post, config, mcp);
386
725
  if (evalRes.isWorthy) {
387
726
  worthyCount++;
388
- log(`[ScrollDuty] ⭐ 發現極高價值內容!審查機制: [${evalRes.reviewMethod.toUpperCase()}], 評分: ${evalRes.score}/100, 作者: @${post.user?.username || 'unknown'}`);
389
- log(`[ScrollDuty] 推薦原因: ${evalRes.reason}`);
727
+ log(`[ScrollDuty] ⭐ 發現契合主人對話記憶與偏好的高分內容!審查機制: [${evalRes.reviewMethod.toUpperCase()}], 評分: ${evalRes.score}/100, 作者: @${post.user?.username || 'unknown'}`);
728
+ log(`[ScrollDuty] 選片依據: ${evalRes.reason}`);
390
729
  // 檢查是否還能推坑 (每日上限控制)
391
730
  const cap = config.dailyRecommendCap || 5;
392
731
  if (ledger.canRecommend(cap) && master) {
393
- // @ 前進行最後確認檢查 (Pre-mention Verification)
394
- if (!evalRes.commentText || !evalRes.commentText.includes(master)) {
395
- log(`[ScrollDuty] ⚠️ 推坑語句缺少 @${master},自動補上主人名稱`);
396
- evalRes.commentText = `@${master} ${evalRes.commentText || '這篇分析非常到位,特地推薦你看看!'}`;
732
+ if (ledger.hasEverSent(post)) {
733
+ log(`[ScrollDuty] ⏭️ 貼文/Reel (${postPk}) 已經在其他任務發送過推坑留言,攔截重複發送!`);
734
+ continue;
735
+ }
736
+ const botUserLower = String(mcpSvc?.loginStatus?.username || mcpSvc?.config?.username || '').replace(/^@/, '').trim().toLowerCase();
737
+ try {
738
+ const ctxCheck = await mcp.igGetPostContext(postPk, 15);
739
+ const existingComments = ctxCheck?.comments || [];
740
+ const alreadyCommented = existingComments.some((c) => {
741
+ const cu = String(c.user?.username || '').toLowerCase();
742
+ const ct = String(c.text || '').toLowerCase();
743
+ return (botUserLower && cu === botUserLower) || (master && ct.includes(`@${master.toLowerCase()}`));
744
+ });
745
+ if (alreadyCommented) {
746
+ log(`[ScrollDuty] ⏭️ 偵測到貼文/Reel (${postPk}) 留言區已有機器人或 @${master} 的推坑紀錄,永久標記並跳過重複發送!`);
747
+ ledger.markSeen(post, true);
748
+ continue;
749
+ }
397
750
  }
751
+ catch { }
752
+ // @ 前進行最後內容具體性核驗 (絕不允許空泛套話或無內容預設句)
753
+ const cleanBody = sanitizeRecommendText(evalRes.commentText || '', master);
754
+ if (!cleanBody) {
755
+ log(`[ScrollDuty] ⏭️ 推坑語句過於空泛或含推薦套話,拒絕發送罐頭留言 (${postPk})`);
756
+ ledger.markSeen(post, false);
757
+ continue;
758
+ }
759
+ evalRes.commentText = `@${master} ${cleanBody}`;
398
760
  log(`[ScrollDuty] 正在貼文下留言推坑: "${evalRes.commentText}"...`);
399
761
  try {
400
762
  const commentRes = await mcp.igPostComment({
@@ -408,7 +770,7 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
408
770
  /action block|feedback|checkpoint|please wait|rate limit/i.test(commentRes.error))) {
409
771
  ledger.triggerCooldown(config.cooldownOnBlockMinutes || 30, commentRes.error || 'Instagram 頻率風控阻擋');
410
772
  log(`[ScrollDuty] 🚨 偵測到風控或熔斷保護,停止後續推坑!`);
411
- ledger.markSeen(postPk, true);
773
+ ledger.markSeen(post, true);
412
774
  break;
413
775
  }
414
776
  if (commentRes?.ok) {
@@ -439,12 +801,12 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
439
801
  }
440
802
  else {
441
803
  log(`[ScrollDuty] 留言發布失敗: ${commentRes?.error || '未知原因'}`);
442
- ledger.markSeen(postPk, true);
804
+ ledger.markSeen(post, true);
443
805
  }
444
806
  }
445
807
  catch (e) {
446
808
  log(`[ScrollDuty] 留言異常: ${e.message}`);
447
- ledger.markSeen(postPk, true);
809
+ ledger.markSeen(post, true);
448
810
  if (/feedback|action block|checkpoint|rate limit/i.test(e.message)) {
449
811
  ledger.triggerCooldown(config.cooldownOnBlockMinutes || 30, e.message);
450
812
  break;
@@ -453,15 +815,24 @@ export async function executeScrollRound(ctx, config, mcp, ledger, log = console
453
815
  }
454
816
  else {
455
817
  log(`[ScrollDuty] 今日推薦已達上限 (${cap}則) 或未配置主人帳號,僅標記值得並記錄日報。`);
456
- ledger.markSeen(postPk, true);
818
+ ledger.markSeen(post, true);
457
819
  }
458
820
  }
459
821
  else {
460
822
  log(`[ScrollDuty] ⏭️ 未通過內容審核: ${evalRes.rejectionReason || '評分未達標準'}`);
461
- ledger.markSeen(postPk, false);
823
+ ledger.markSeen(post, false);
462
824
  }
463
825
  }
464
- const msg = `本輪滾動完畢!共掃描 ${totalScanned} 則內容,經正文與語義嚴格審查,精選 ${worthyCount} 則符合主人偏好,成功留評推薦 ${recommendedCount} 則。`;
826
+ let msg = '';
827
+ if (totalScanned > 0) {
828
+ msg = `本輪滾動完畢!共掃描 ${totalScanned} 則新內容,經正文與語義嚴格審查,精選 ${worthyCount} 則符合主人偏好,成功留評推薦 ${recommendedCount} 則。`;
829
+ }
830
+ else if (candidatePosts.length > 0) {
831
+ msg = `本輪滾動完畢!共獲取到 ${candidatePosts.length} 則動態,但均為今日已審閱過之內容(帳本已自動去重,避免重複審查與打擾),暫無未看過之新貼文。`;
832
+ }
833
+ else {
834
+ msg = `本輪滾動完畢!未檢索到動態內容(可能是 Instagram 官方暫時無新動態推薦或標籤搜尋受限)。`;
835
+ }
465
836
  log(`[ScrollDuty] 🎉 ${msg}`);
466
837
  return {
467
838
  ok: true,