@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 +31 -9
- package/dist/cli.js +3 -12
- package/dist/config.d.ts +0 -1
- package/dist/config.js +2 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +21 -52
- package/dist/telegram.d.ts +1 -2
- package/dist/telegram.js +3 -5
- package/package.json +2 -3
- package/dist/threads.d.ts +0 -13
- package/dist/threads.js +0 -34
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# banini-tracker
|
|
6
6
|
|
|
7
|
-
追蹤「股海冥燈」巴逆逆(8zz)的
|
|
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):
|
|
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` | 單次執行(
|
|
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> 來源:
|
|
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 |
|
|
125
|
-
|
|
|
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>', '來源:
|
|
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
|
-
|
|
76
|
-
|
|
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
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?.
|
|
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 # 單次執行:
|
|
4
|
+
* npm run dev # 單次執行:Facebook 3 篇
|
|
5
5
|
* npm run dry # 只抓取,不呼叫 LLM
|
|
6
|
-
* npm run market # 單次盤中模式:FB
|
|
7
|
-
* npm run evening # 單次盤後模式:
|
|
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 # 單次執行:
|
|
4
|
+
* npm run dev # 單次執行:Facebook 3 篇
|
|
5
5
|
* npm run dry # 只抓取,不呼叫 LLM
|
|
6
|
-
* npm run market # 單次盤中模式:FB
|
|
7
|
-
* npm run evening # 單次盤後模式:
|
|
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. 抓取
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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} 篇新貼文(
|
|
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 =
|
|
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 =
|
|
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, {
|
|
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({
|
|
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({
|
|
204
|
+
run({ maxPosts: 1, isDryRun: false, label: '盤中' })
|
|
234
205
|
.catch((err) => console.error('[盤中] 執行失敗:', err));
|
|
235
206
|
}, { timezone: 'Asia/Taipei' });
|
|
236
|
-
// 盤後:每天晚上 23:00,
|
|
207
|
+
// 盤後:每天晚上 23:00,FB 3 篇
|
|
237
208
|
cron.schedule('3 23 * * *', () => {
|
|
238
|
-
run({
|
|
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
|
|
243
|
-
console.log(' 盤後:每天 23:03(
|
|
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({
|
|
222
|
+
run({ maxPosts, isDryRun, label: '手動' }).catch((err) => {
|
|
254
223
|
console.error('執行失敗:', err);
|
|
255
224
|
process.exit(1);
|
|
256
225
|
});
|
package/dist/telegram.d.ts
CHANGED
|
@@ -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: '
|
|
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(`來源:
|
|
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(
|
|
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(
|
|
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.
|
|
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 --
|
|
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
|
-
}
|