@cablate/banini-tracker 2.0.1 → 2.0.3

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # banini-tracker
6
6
 
7
- 追蹤「股海冥燈」巴逆逆(8zz)的 Threads / Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送。
7
+ 追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送。
8
8
 
9
9
  - 辨識她提到的標的(個股、ETF、原物料)
10
10
  - 判斷她的操作(買入 / 被套 / 停損)
@@ -35,14 +35,14 @@ npm install && npm run start
35
35
  ### 排程規則
36
36
 
37
37
  - **盤中**(週一~五 09:00-13:30):每 30 分鐘,FB only 抓 1 篇
38
- - **盤後**(每天 23:00):Threads + FB 3 篇
38
+ - **盤後**(每天 23:00):FB 3 篇
39
39
 
40
40
  ### npm scripts
41
41
 
42
42
  | 指令 | 說明 |
43
43
  |------|------|
44
44
  | `npm run start` | 常駐排程模式(盤中 + 盤後自動跑) |
45
- | `npm run dev` | 單次執行(Threads + FB 3 篇) |
45
+ | `npm run dev` | 單次執行(FB 3 篇) |
46
46
  | `npm run dry` | 只抓取,不呼叫 LLM |
47
47
  | `npm run market` | 盤中模式(FB only, 1 篇) |
48
48
  | `npm run evening` | 盤後模式(各 3 篇) |
@@ -91,7 +91,7 @@ npx @cablate/banini-tracker push -m "分析結果..."
91
91
  ### fetch 選項
92
92
 
93
93
  ```
94
- -s, --source <source> 來源:threads / fb / both(預設 fb)
94
+ -s, --source <source> 來源:fb(預設 fb)
95
95
  -n, --limit <n> 每個來源抓幾篇(預設 3)
96
96
  --no-dedup 不去重
97
97
  --mark-seen 輸出後自動標記已讀
@@ -117,12 +117,34 @@ npx @cablate/banini-tracker push -m "分析結果..."
117
117
 
118
118
  詳見 [`skill/SKILL.md`](skill/SKILL.md)。
119
119
 
120
- ## 費用
120
+ ## 費用估算
121
121
 
122
- | 來源 | 每次費用 | 說明 |
123
- |------|---------|------|
124
- | Facebook | ~$0.02 | CU 計費,便宜 |
125
- | Threads | ~$0.15 | Pay-per-event,較貴 |
122
+ | 項目 | 單次費用 | 頻率 | 月估算 |
123
+ |------|---------|------|--------|
124
+ | Facebook 抓取(Apify) | ~$0.02 | 盤中 ~198 次 + 盤後 30 次 | ~$4.56 |
125
+ | LLM 分析(常駐模式) | 依模型而定 | 同上 | 依模型定價 |
126
+ | Telegram 推送 | 免費 | — | $0 |
127
+
128
+ > 盤中:週一~五 09:00-13:30 每 30 分鐘(~9 次/日 × 22 工作日)
129
+ > 盤後:每天 23:00(30 次/月)
130
+ > CLI 模式搭配 Claude Code 使用則不需 LLM 費用,Claude 自己分析
131
+
132
+ ## 為什麼只用 Facebook?
133
+
134
+ 早期版本同時支援 Threads 和 Facebook 爬取,後來基於兩個原因移除了 Threads:
135
+
136
+ 1. **費用差距大**:Threads 每次抓取 ~$0.15(Pay-per-event),Facebook 只要 ~$0.02(CU 計費),差 7 倍以上
137
+ 2. **FB 參考價值更高**:巴逆逆的投資相關貼文(持倉截圖、操作心得)主要發在 Facebook 粉專,Threads 多為生活日常,反指標參考價值較低
138
+
139
+ ## Star History
140
+
141
+ <a href="https://star-history.com/#cablate/banini-tracker&Date">
142
+ <picture>
143
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=cablate/banini-tracker&type=Date&theme=dark" />
144
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=cablate/banini-tracker&type=Date" />
145
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=cablate/banini-tracker&type=Date" />
146
+ </picture>
147
+ </a>
126
148
 
127
149
  ## 免責聲明
128
150
 
package/dist/cli.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { loadConfig, saveConfig, defaultConfig, getConfigPath, getSeenFile } from './config.js';
4
- import { fetchThreadsPosts } from './threads.js';
5
4
  import { fetchFacebookPosts } from './facebook.js';
6
5
  import { sendTelegramMessage } from './telegram.js';
7
6
  import { filterNewPosts, markPostsSeen, listSeenIds, clearSeen } from './seen.js';
@@ -18,7 +17,6 @@ program
18
17
  .option('--apify-token <token>', 'Apify API token')
19
18
  .option('--tg-bot-token <token>', 'Telegram Bot token')
20
19
  .option('--tg-channel-id <id>', 'Telegram Channel ID')
21
- .option('--threads-username <name>', 'Threads 使用者名稱', 'banini31')
22
20
  .option('--fb-page-url <url>', 'Facebook 粉專網址', 'https://www.facebook.com/DieWithoutBang/')
23
21
  .action((opts) => {
24
22
  const config = defaultConfig();
@@ -30,7 +28,6 @@ program
30
28
  channelId: opts.tgChannelId ?? '',
31
29
  };
32
30
  }
33
- config.targets.threadsUsername = opts.threadsUsername;
34
31
  config.targets.facebookPageUrl = opts.fbPageUrl;
35
32
  saveConfig(config);
36
33
  console.error(`設定已寫入: ${getConfigPath()}`);
@@ -63,7 +60,7 @@ program
63
60
  program
64
61
  .command('fetch')
65
62
  .description('抓取最新貼文(輸出 JSON 到 stdout)')
66
- .option('-s, --source <source>', '來源:threads / fb / both', 'fb')
63
+ .option('-s, --source <source>', '來源:fb', 'fb')
67
64
  .option('-n, --limit <n>', '每個來源抓幾篇', '3')
68
65
  .option('--no-dedup', '不做去重,抓到什麼就輸出什麼')
69
66
  .option('--mark-seen', '輸出後自動標記為已讀')
@@ -72,14 +69,8 @@ program
72
69
  const config = loadConfig();
73
70
  const limit = parseInt(opts.limit, 10);
74
71
  let posts = [];
75
- if (opts.source === 'threads' || opts.source === 'both') {
76
- const tp = await fetchThreadsPosts(config.targets.threadsUsername, config.apifyToken, limit);
77
- posts.push(...tp);
78
- }
79
- if (opts.source === 'fb' || opts.source === 'both') {
80
- const fp = await fetchFacebookPosts(config.targets.facebookPageUrl, config.apifyToken, limit);
81
- posts.push(...fp);
82
- }
72
+ const fp = await fetchFacebookPosts(config.targets.facebookPageUrl, config.apifyToken, limit);
73
+ posts.push(...fp);
83
74
  // 按時間從新到舊
84
75
  posts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
85
76
  // 去重
package/dist/config.d.ts CHANGED
@@ -5,7 +5,6 @@ export interface Config {
5
5
  channelId: string;
6
6
  };
7
7
  targets: {
8
- threadsUsername: string;
9
8
  facebookPageUrl: string;
10
9
  };
11
10
  }
package/dist/config.js CHANGED
@@ -20,8 +20,8 @@ export function loadConfig() {
20
20
  const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
21
21
  if (!raw.apifyToken)
22
22
  throw new Error('設定檔缺少 apifyToken');
23
- if (!raw.targets?.threadsUsername || !raw.targets?.facebookPageUrl) {
24
- throw new Error('設定檔缺少 targets 設定');
23
+ if (!raw.targets?.facebookPageUrl) {
24
+ throw new Error('設定檔缺少 targets.facebookPageUrl 設定');
25
25
  }
26
26
  return raw;
27
27
  }
@@ -36,7 +36,6 @@ export function defaultConfig() {
36
36
  channelId: '',
37
37
  },
38
38
  targets: {
39
- threadsUsername: 'banini31',
40
39
  facebookPageUrl: 'https://www.facebook.com/DieWithoutBang/',
41
40
  },
42
41
  };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * 巴逆逆(8zz)反指標追蹤器
3
3
  *
4
- * npm run dev # 單次執行:Threads + Facebook(各 3 篇)
4
+ * npm run dev # 單次執行:Facebook 3
5
5
  * npm run dry # 只抓取,不呼叫 LLM
6
- * npm run market # 單次盤中模式:FB only, 1 篇
7
- * npm run evening # 單次盤後模式:Threads + FB, 3 篇
6
+ * npm run market # 單次盤中模式:FB 1 篇
7
+ * npm run evening # 單次盤後模式:FB 3 篇
8
8
  * npm run cron # 常駐排程:盤中每 30 分 + 盤後 23:00
9
9
  */
10
10
  import 'dotenv/config';
package/dist/index.js CHANGED
@@ -1,24 +1,22 @@
1
1
  /**
2
2
  * 巴逆逆(8zz)反指標追蹤器
3
3
  *
4
- * npm run dev # 單次執行:Threads + Facebook(各 3 篇)
4
+ * npm run dev # 單次執行:Facebook 3
5
5
  * npm run dry # 只抓取,不呼叫 LLM
6
- * npm run market # 單次盤中模式:FB only, 1 篇
7
- * npm run evening # 單次盤後模式:Threads + FB, 3 篇
6
+ * npm run market # 單次盤中模式:FB 1 篇
7
+ * npm run evening # 單次盤後模式:FB 3 篇
8
8
  * npm run cron # 常駐排程:盤中每 30 分 + 盤後 23:00
9
9
  */
10
10
  import 'dotenv/config';
11
11
  import { writeFileSync, mkdirSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import cron from 'node-cron';
14
- import { fetchThreadsPosts } from './threads.js';
15
14
  import { fetchFacebookPosts } from './facebook.js';
16
15
  import { analyzePosts } from './analyze.js';
17
16
  import { sendTelegramMessageWithConfig, formatReport, formatFallbackReport } from './telegram.js';
18
17
  import { filterNewPosts as filterNew, markPostsSeen } from './seen.js';
19
18
  import { withRetry } from './retry.js';
20
19
  // ── Config ──────────────────────────────────────────────────
21
- const THREADS_USERNAME = 'banini31';
22
20
  const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';
23
21
  const DATA_DIR = join(process.cwd(), 'data');
24
22
  const isCronMode = process.argv.includes('--cron');
@@ -28,20 +26,6 @@ function env(key, fallback) {
28
26
  throw new Error(`Missing env: ${key}`);
29
27
  return val;
30
28
  }
31
- function fromThreads(p) {
32
- return {
33
- id: p.id,
34
- source: 'threads',
35
- text: p.text,
36
- timestamp: p.timestamp,
37
- likeCount: p.likeCount,
38
- replyCount: p.replyCount,
39
- url: p.url,
40
- mediaType: p.mediaType,
41
- mediaUrl: p.mediaUrl,
42
- ocrText: p.ocrText,
43
- };
44
- }
45
29
  function fromFacebook(p) {
46
30
  return {
47
31
  id: p.id,
@@ -76,25 +60,13 @@ async function runInner(opts) {
76
60
  console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`);
77
61
  const apifyToken = env('APIFY_TOKEN');
78
62
  const allPosts = [];
79
- // 1. 抓取 Threads(含 retry)
80
- if (!opts.fbOnly) {
81
- try {
82
- const threadsPosts = await withRetry(() => fetchThreadsPosts(THREADS_USERNAME, apifyToken, opts.maxPosts), { label: 'Threads', maxRetries: 2, baseDelayMs: 5000 });
83
- allPosts.push(...threadsPosts.map(fromThreads));
84
- }
85
- catch (err) {
86
- console.error(`[Threads] 抓取失敗: ${err instanceof Error ? err.message : err}`);
87
- }
63
+ // 1. 抓取 Facebook(含 retry)
64
+ try {
65
+ const fbPosts = await withRetry(() => fetchFacebookPosts(FB_PAGE_URL, apifyToken, opts.maxPosts), { label: 'Facebook', maxRetries: 2, baseDelayMs: 5000 });
66
+ allPosts.push(...fbPosts.map(fromFacebook));
88
67
  }
89
- // 2. 抓取 Facebook(含 retry)
90
- if (!opts.threadsOnly) {
91
- try {
92
- const fbPosts = await withRetry(() => fetchFacebookPosts(FB_PAGE_URL, apifyToken, opts.maxPosts), { label: 'Facebook', maxRetries: 2, baseDelayMs: 5000 });
93
- allPosts.push(...fbPosts.map(fromFacebook));
94
- }
95
- catch (err) {
96
- console.error(`[Facebook] 抓取失敗: ${err instanceof Error ? err.message : err}`);
97
- }
68
+ catch (err) {
69
+ console.error(`[Facebook] 抓取失敗: ${err instanceof Error ? err.message : err}`);
98
70
  }
99
71
  if (allPosts.length === 0) {
100
72
  console.log('沒有抓到任何貼文,結束');
@@ -114,14 +86,13 @@ async function runInner(opts) {
114
86
  const postDate = new Date(ts).toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' });
115
87
  return postDate === todayStr;
116
88
  };
117
- const threadCount = newPosts.filter((p) => p.source === 'threads').length;
118
- const fbCount = newPosts.filter((p) => p.source === 'facebook').length;
89
+ const fbCount = newPosts.length;
119
90
  const todayCount = newPosts.filter((p) => isToday(p.timestamp)).length;
120
- console.log(`發現 ${newPosts.length} 篇新貼文(Threads: ${threadCount}, FB: ${fbCount}, 今日: ${todayCount})\n`);
91
+ console.log(`發現 ${newPosts.length} 篇新貼文(FB: ${fbCount}, 今日: ${todayCount})\n`);
121
92
  markPostsSeen(newPosts.map((p) => p.id));
122
93
  // 4. 印出貼文
123
94
  for (const p of newPosts) {
124
- const tag = p.source === 'threads' ? 'TH' : 'FB';
95
+ const tag = 'FB';
125
96
  const todayTag = isToday(p.timestamp) ? ' [今天]' : '';
126
97
  const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
127
98
  console.log(`--- [${tag}]${todayTag} ${localTime} [${p.mediaType}] ---`);
@@ -138,7 +109,7 @@ async function runInner(opts) {
138
109
  const textsForAnalysis = newPosts
139
110
  .filter((p) => p.text.trim().length > 0 || p.ocrText.trim().length > 0)
140
111
  .map((p) => {
141
- const tag = p.source === 'threads' ? 'Threads' : 'Facebook';
112
+ const tag = 'Facebook';
142
113
  const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
143
114
  let content = `[${tag}] ${p.text}`;
144
115
  if (p.ocrText)
@@ -204,7 +175,7 @@ async function runInner(opts) {
204
175
  }));
205
176
  const msg = llmFailed
206
177
  ? formatFallbackReport(postSummaries)
207
- : formatReport(analysis, { threads: threadCount, fb: fbCount }, postSummaries);
178
+ : formatReport(analysis, { fb: fbCount }, postSummaries);
208
179
  await withRetry(() => sendTelegramMessageWithConfig({ botToken: tgToken, channelId: tgChannelId }, msg), { label: 'Telegram', maxRetries: 3, baseDelayMs: 3000 });
209
180
  console.log('[Telegram] 通知已發送');
210
181
  }
@@ -226,31 +197,29 @@ if (isCronMode) {
226
197
  // 盤中:週一到五 09:00-13:30,每 30 分鐘,FB only 抓 1 篇
227
198
  // cron 不支援半小時結束,用 9:00-13:00 每 30 分 + 13:30 單獨一個
228
199
  cron.schedule('7,37 9-12 * * 1-5', () => {
229
- run({ fbOnly: true, threadsOnly: false, maxPosts: 1, isDryRun: false, label: '盤中' })
200
+ run({ maxPosts: 1, isDryRun: false, label: '盤中' })
230
201
  .catch((err) => console.error('[盤中] 執行失敗:', err));
231
202
  }, { timezone: 'Asia/Taipei' });
232
203
  cron.schedule('7 13 * * 1-5', () => {
233
- run({ fbOnly: true, threadsOnly: false, maxPosts: 1, isDryRun: false, label: '盤中' })
204
+ run({ maxPosts: 1, isDryRun: false, label: '盤中' })
234
205
  .catch((err) => console.error('[盤中] 執行失敗:', err));
235
206
  }, { timezone: 'Asia/Taipei' });
236
- // 盤後:每天晚上 23:00,Threads + FB 3 篇
207
+ // 盤後:每天晚上 23:00,FB 3 篇
237
208
  cron.schedule('3 23 * * *', () => {
238
- run({ fbOnly: false, threadsOnly: false, maxPosts: 3, isDryRun: false, label: '盤後' })
209
+ run({ maxPosts: 3, isDryRun: false, label: '盤後' })
239
210
  .catch((err) => console.error('[盤後] 執行失敗:', err));
240
211
  }, { timezone: 'Asia/Taipei' });
241
212
  console.log('=== 巴逆逆排程已啟動 ===');
242
- console.log(' 盤中:週一~五 09:07/09:37/10:07/.../13:07(FB only, 1 篇)');
243
- console.log(' 盤後:每天 23:03(Threads + FB, 3 篇)');
213
+ console.log(' 盤中:週一~五 09:07/09:37/10:07/.../13:07(FB, 1 篇)');
214
+ console.log(' 盤後:每天 23:03(FB, 3 篇)');
244
215
  console.log(' 按 Ctrl+C 停止\n');
245
216
  }
246
217
  else {
247
218
  // 單次執行模式
248
219
  const isDryRun = process.argv.includes('--dry');
249
- const threadsOnly = process.argv.includes('--threads-only');
250
- const fbOnly = process.argv.includes('--fb-only');
251
220
  const maxPostsArg = process.argv.find((a) => a.startsWith('--max-posts='));
252
221
  const maxPosts = maxPostsArg ? parseInt(maxPostsArg.split('=')[1], 10) : 3;
253
- run({ fbOnly, threadsOnly, maxPosts, isDryRun, label: '手動' }).catch((err) => {
222
+ run({ maxPosts, isDryRun, label: '手動' }).catch((err) => {
254
223
  console.error('執行失敗:', err);
255
224
  process.exit(1);
256
225
  });
@@ -5,7 +5,7 @@ export interface TelegramConfig {
5
5
  }
6
6
  export declare function sendTelegramMessageWithConfig(config: TelegramConfig, text: string): Promise<void>;
7
7
  interface PostSummary {
8
- source: 'threads' | 'facebook';
8
+ source: 'facebook';
9
9
  timestamp: string;
10
10
  isToday: boolean;
11
11
  text: string;
@@ -26,7 +26,6 @@ export declare function formatReport(analysis: {
26
26
  actionableSuggestion?: string;
27
27
  moodScore?: number;
28
28
  }, postCount: {
29
- threads: number;
30
29
  fb: number;
31
30
  }, posts: PostSummary[]): string;
32
31
  export declare function formatFallbackReport(posts: PostSummary[]): string;
package/dist/telegram.js CHANGED
@@ -28,15 +28,14 @@ function escapeHtml(text) {
28
28
  export function formatReport(analysis, postCount, posts) {
29
29
  const lines = [];
30
30
  lines.push('<b>巴逆逆反指標速報</b>');
31
- lines.push(`來源:Threads ${postCount.threads} 篇 / FB ${postCount.fb} 篇`);
31
+ lines.push(`來源:FB ${postCount.fb} 篇`);
32
32
  lines.push('');
33
33
  lines.push('<b>她的動態</b>');
34
34
  for (const p of posts) {
35
- const src = p.source === 'threads' ? 'TH' : 'FB';
36
35
  const todayTag = p.isToday ? ' [今天]' : '';
37
36
  const preview = escapeHtml(p.text.replace(/\n/g, ' ').slice(0, 50));
38
37
  const link = p.url ? ` <a href="${p.url}">原文</a>` : '';
39
- lines.push(`${src}${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}${link}`);
38
+ lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}${link}`);
40
39
  }
41
40
  lines.push('');
42
41
  lines.push(escapeHtml(analysis.summary));
@@ -79,11 +78,10 @@ export function formatFallbackReport(posts) {
79
78
  lines.push('<b>巴逆逆貼文速報</b>(LLM 分析失敗)');
80
79
  lines.push('');
81
80
  for (const p of posts) {
82
- const src = p.source === 'threads' ? 'TH' : 'FB';
83
81
  const todayTag = p.isToday ? ' [今天]' : '';
84
82
  const preview = escapeHtml(p.text.replace(/\n/g, ' ').slice(0, 80));
85
83
  const link = p.url ? ` <a href="${p.url}">原文</a>` : '';
86
- lines.push(`${src}${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`);
84
+ lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`);
87
85
  }
88
86
  lines.push('\n<i>LLM 服務暫時無法使用,僅列出原始貼文</i>');
89
87
  return lines.join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cablate/banini-tracker",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "dev": "tsx src/index.ts",
11
11
  "dry": "tsx src/index.ts --dry",
12
- "market": "tsx src/index.ts --fb-only --max-posts=1",
12
+ "market": "tsx src/index.ts --max-posts=1",
13
13
  "evening": "tsx src/index.ts --max-posts=3",
14
14
  "cron": "tsx src/index.ts --cron",
15
15
  "cli": "tsx src/cli.ts",
@@ -20,7 +20,6 @@
20
20
  "keywords": [
21
21
  "banini",
22
22
  "reverse-indicator",
23
- "threads",
24
23
  "facebook",
25
24
  "apify",
26
25
  "telegram",
package/dist/threads.d.ts DELETED
@@ -1,13 +0,0 @@
1
- export interface ThreadsPost {
2
- id: string;
3
- source: 'threads';
4
- text: string;
5
- timestamp: string;
6
- likeCount: number;
7
- replyCount: number;
8
- url: string;
9
- mediaType: string;
10
- mediaUrl: string;
11
- ocrText: string;
12
- }
13
- export declare function fetchThreadsPosts(username: string, token: string, maxPosts?: number): Promise<ThreadsPost[]>;
package/dist/threads.js DELETED
@@ -1,34 +0,0 @@
1
- export async function fetchThreadsPosts(username, token, maxPosts = 3) {
2
- const actorId = 'futurizerush~meta-threads-scraper';
3
- const url = `https://api.apify.com/v2/acts/${actorId}/run-sync-get-dataset-items`;
4
- const res = await fetch(url, {
5
- method: 'POST',
6
- headers: {
7
- 'Content-Type': 'application/json',
8
- Authorization: `Bearer ${token}`,
9
- },
10
- body: JSON.stringify({
11
- mode: 'user',
12
- usernames: [username],
13
- max_posts: maxPosts,
14
- }),
15
- signal: AbortSignal.timeout(120_000),
16
- });
17
- if (!res.ok) {
18
- const body = await res.text().catch(() => '');
19
- throw new Error(`Apify Threads 請求失敗: ${res.status} ${body.slice(0, 200)}`);
20
- }
21
- const raw = (await res.json());
22
- return raw.map((item) => ({
23
- id: item.post_code ?? item.id ?? item.code ?? String(item.pk),
24
- source: 'threads',
25
- text: item.text_content ?? item.text ?? item.caption ?? '',
26
- timestamp: item.created_at ?? item.timestamp ?? new Date().toISOString(),
27
- likeCount: item.like_count ?? item.likeCount ?? 0,
28
- replyCount: item.reply_count ?? item.replyCount ?? 0,
29
- url: item.post_url ?? item.url ?? `https://www.threads.net/@${username}/post/${item.post_code ?? item.id}`,
30
- mediaType: item.media_type ?? 'text',
31
- mediaUrl: item.media_url ?? '',
32
- ocrText: '',
33
- }));
34
- }