@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.
- package/AGENTS.md +92 -0
- package/README.md +77 -202
- package/README.zh-Hant.md +73 -204
- package/client.js +102 -30
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +40 -5
- package/dist/config.js.map +1 -1
- package/dist/core/scrollDuty.d.ts +4 -2
- package/dist/core/scrollDuty.d.ts.map +1 -1
- package/dist/core/scrollDuty.js +524 -153
- package/dist/core/scrollDuty.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/ledger.d.ts +66 -10
- package/dist/lib/ledger.d.ts.map +1 -1
- package/dist/lib/ledger.js +379 -22
- package/dist/lib/ledger.js.map +1 -1
- package/package.json +4 -3
package/dist/core/scrollDuty.js
CHANGED
|
@@ -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
|
-
*
|
|
279
|
+
* 調用共享外部插件訂閱額度 (dsh-plugin-subscriptions / __DSH_INSTAGRAM_LLM__)、宿主 LLM 或自訂 API 進行深度語義內容審查
|
|
46
280
|
*/
|
|
47
|
-
async function callLlmInference(ctx, systemPrompt, userPrompt) {
|
|
48
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
354
|
+
* 解決痛點:
|
|
355
|
+
* 1. 根據與用戶對話的記憶 (dsh-fish-memory) 動態選擇用戶可能喜歡的內容才做推薦
|
|
356
|
+
* 2. 推薦時的 @+文字 必須根據該貼文或 Reel 的具體內容動態生成,嚴禁空泛說「很推薦...」或使用罐頭模板
|
|
109
357
|
*/
|
|
110
|
-
export async function inspectAndEvaluatePost(ctx, post, config) {
|
|
111
|
-
const
|
|
112
|
-
const
|
|
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
|
-
'
|
|
117
|
-
//
|
|
118
|
-
|
|
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:
|
|
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
|
|
156
|
-
const positiveWords =
|
|
157
|
-
|
|
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 &&
|
|
467
|
+
if (cleanKw && searchableLower.includes(cleanKw)) {
|
|
162
468
|
hitBodyKeywords.push(cleanKw);
|
|
163
469
|
}
|
|
164
470
|
}
|
|
165
|
-
//
|
|
166
|
-
if (config.requireBodyMatch &&
|
|
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
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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:
|
|
554
|
+
score: parsedEval.score || 35,
|
|
289
555
|
reason: '',
|
|
290
|
-
rejectionReason:
|
|
556
|
+
rejectionReason: parsedEval.rejectionReason || '經 AI 結合主人對話記憶與貼文內容審查,未達推薦標準。',
|
|
291
557
|
commentText: '',
|
|
292
|
-
reviewMethod: '
|
|
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
|
|
588
|
+
const mcpSvc = globalThis.__DSH_INSTAGRAM_MCP__;
|
|
589
|
+
const master = (mcpSvc?.config?.masterUsername || config.masterUsername || '').replace(/^@/, '').trim();
|
|
314
590
|
if (!master) {
|
|
315
|
-
log('[ScrollDuty] ⚠️
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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] ⭐
|
|
389
|
-
log(`[ScrollDuty]
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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(
|
|
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(
|
|
804
|
+
ledger.markSeen(post, true);
|
|
443
805
|
}
|
|
444
806
|
}
|
|
445
807
|
catch (e) {
|
|
446
808
|
log(`[ScrollDuty] 留言異常: ${e.message}`);
|
|
447
|
-
ledger.markSeen(
|
|
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(
|
|
818
|
+
ledger.markSeen(post, true);
|
|
457
819
|
}
|
|
458
820
|
}
|
|
459
821
|
else {
|
|
460
822
|
log(`[ScrollDuty] ⏭️ 未通過內容審核: ${evalRes.rejectionReason || '評分未達標準'}`);
|
|
461
|
-
ledger.markSeen(
|
|
823
|
+
ledger.markSeen(post, false);
|
|
462
824
|
}
|
|
463
825
|
}
|
|
464
|
-
|
|
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,
|