@cablate/banini-tracker 2.0.0 → 2.0.2

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 cablate
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cablate
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,126 +1,138 @@
1
- <p align="center">
2
- <img src="assets/banner.svg" alt="banini-tracker banner" width="100%">
3
- </p>
4
-
5
- # banini-tracker
6
-
7
- 追蹤「股海冥燈」巴逆逆(8zz)的 Threads / Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送。
8
-
9
- 支援兩種使用模式:
10
- - **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + Telegram 推送
11
- - **CLI 工具**:`npx banini-tracker`,搭配 Claude Code 等 AI 手動執行分析
12
-
13
- ## 快速開始(常駐排程)
14
-
15
- ```bash
16
- # 1. 複製設定
17
- cp .env.example .env
18
- # 填入 APIFY_TOKEN, LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, TG_BOT_TOKEN, TG_CHANNEL_ID
19
-
20
- # 2. Docker 部署
21
- docker build -t banini-tracker .
22
- docker run -d --name banini --env-file .env banini-tracker
23
-
24
- # 3. 或本地直接跑
25
- npm install && npm run start
26
- ```
27
-
28
- ### 排程規則
29
-
30
- - **盤中**(週一~五 09:00-13:30):每 30 分鐘,FB only 抓 1 篇
31
- - **盤後**(每天 23:00):Threads + FB 各 3
32
-
33
- ### npm scripts
34
-
35
- | 指令 | 說明 |
36
- |------|------|
37
- | `npm run start` | 常駐排程模式(盤中 + 盤後自動跑) |
38
- | `npm run dev` | 單次執行(Threads + FB 3 篇) |
39
- | `npm run dry` | 只抓取,不呼叫 LLM |
40
- | `npm run market` | 盤中模式(FB only, 1 篇) |
41
- | `npm run evening` | 盤後模式(各 3 篇) |
42
-
43
- ### .env 設定
44
-
45
- ```
46
- APIFY_TOKEN=apify_api_...
47
- LLM_BASE_URL=https://api.deepinfra.com/v1/openai
48
- LLM_API_KEY=...
49
- LLM_MODEL=MiniMaxAI/MiniMax-M2.5
50
- TG_BOT_TOKEN=...
51
- TG_CHANNEL_ID=-100...
52
- ```
53
-
54
- ## CLI 工具模式
55
-
56
- 不需 clone repo,任何環境直接用:
57
-
58
- ```bash
59
- # 初始化設定
60
- npx banini-tracker init \
61
- --apify-token YOUR_APIFY_TOKEN \
62
- --tg-bot-token YOUR_TG_BOT_TOKEN \
63
- --tg-channel-id YOUR_TG_CHANNEL_ID
64
-
65
- # 抓取 Facebook 最新 3 篇
66
- npx banini-tracker fetch -s fb -n 3 --mark-seen
67
-
68
- # 推送結果到 Telegram
69
- npx banini-tracker push -m "分析結果..."
70
- ```
71
-
72
- ### CLI 指令
73
-
74
- | 指令 | 說明 |
75
- |------|------|
76
- | `init` | 初始化設定檔(`~/.banini-tracker.json`) |
77
- | `config` | 顯示目前設定 |
78
- | `fetch` | 抓取貼文,輸出 JSON 到 stdout |
79
- | `push` | 推送訊息到 Telegram |
80
- | `seen list` | 列出已讀貼文 ID |
81
- | `seen mark <id...>` | 標記貼文為已讀 |
82
- | `seen clear` | 清空已讀紀錄 |
83
-
84
- ### fetch 選項
85
-
86
- ```
87
- -s, --source <source> 來源:threads / fb / both(預設 fb)
88
- -n, --limit <n> 每個來源抓幾篇(預設 3)
89
- --no-dedup 不去重
90
- --mark-seen 輸出後自動標記已讀
91
- ```
92
-
93
- ### push 選項
94
-
95
- ```
96
- -m, --message <text> 直接帶訊息
97
- -f, --file <path> 從檔案讀取
98
- --parse-mode <mode> HTML / Markdown / none(預設 HTML)
99
- ```
100
-
101
- 不帶 `-m` 或 `-f` 時從 stdin 讀取。
102
-
103
- ### 搭配 Claude Code 使用
104
-
105
- Claude Code skill 中,Claude 自己就是分析引擎:
106
-
107
- 1. `fetch` 抓貼文 → Claude 讀 JSON
108
- 2. Claude 分析 + WebSearch 查最新走勢
109
- 3. Claude 組報告 → `push` 推送 Telegram
110
-
111
- 詳見 [`skill/SKILL.md`](skill/SKILL.md)。
112
-
113
- ## 費用
114
-
115
- | 來源 | 每次費用 | 說明 |
116
- |------|---------|------|
117
- | Facebook | ~$0.02 | CU 計費,便宜 |
118
- | Threads | ~$0.15 | Pay-per-event,較貴 |
119
-
120
- ## 免責聲明
121
-
122
- 本專案僅供娛樂參考,不構成任何投資建議。
123
-
124
- ## License
125
-
126
- MIT
1
+ <p align="center">
2
+ <img src="assets/banner.svg" alt="banini-tracker banner" width="100%">
3
+ </p>
4
+
5
+ # banini-tracker
6
+
7
+ 追蹤「股海冥燈」巴逆逆(8zz)的 Facebook 社群貼文,透過 Apify 抓取、AI 反指標分析、Telegram 即時推送。
8
+
9
+ - 辨識她提到的標的(個股、ETF、原物料)
10
+ - 判斷她的操作(買入 / 被套 / 停損)
11
+ - 反轉推導(她停損 可能反彈、她買入 可能下跌)
12
+ - 推導連鎖效應(油價跌 → 製造業利多 → 電子股受惠)
13
+
14
+ > **Claude Code 使用者?** 直接把 [`skill/SKILL.md`](skill/SKILL.md) 加到你的 `.claude/skills/` 就能用。Claude 自己當分析引擎,不需要額外 LLM。
15
+
16
+ 支援兩種使用模式:
17
+ - **常駐排程**:Docker 部署,自動盤中/盤後排程 + LLM 分析 + Telegram 推送
18
+ - **CLI 工具**:`npx @cablate/banini-tracker`,搭配 Claude Code AI 手動執行分析
19
+
20
+ ## 快速開始(常駐排程)
21
+
22
+ ```bash
23
+ # 1. 複製設定
24
+ cp .env.example .env
25
+ # 填入 APIFY_TOKEN, LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, TG_BOT_TOKEN, TG_CHANNEL_ID
26
+
27
+ # 2. Docker 部署
28
+ docker build -t banini-tracker .
29
+ docker run -d --name banini --env-file .env banini-tracker
30
+
31
+ # 3. 或本地直接跑
32
+ npm install && npm run start
33
+ ```
34
+
35
+ ### 排程規則
36
+
37
+ - **盤中**(週一~五 09:00-13:30):每 30 分鐘,FB only 1
38
+ - **盤後**(每天 23:00):FB 3
39
+
40
+ ### npm scripts
41
+
42
+ | 指令 | 說明 |
43
+ |------|------|
44
+ | `npm run start` | 常駐排程模式(盤中 + 盤後自動跑) |
45
+ | `npm run dev` | 單次執行(FB 3 篇) |
46
+ | `npm run dry` | 只抓取,不呼叫 LLM |
47
+ | `npm run market` | 盤中模式(FB only, 1 篇) |
48
+ | `npm run evening` | 盤後模式(各 3 篇) |
49
+
50
+ ### .env 設定
51
+
52
+ ```
53
+ APIFY_TOKEN=apify_api_...
54
+ LLM_BASE_URL=https://api.deepinfra.com/v1/openai
55
+ LLM_API_KEY=...
56
+ LLM_MODEL=MiniMaxAI/MiniMax-M2.5
57
+ TG_BOT_TOKEN=...
58
+ TG_CHANNEL_ID=-100...
59
+ ```
60
+
61
+ ## CLI 工具模式
62
+
63
+ 不需 clone repo,任何環境直接用:
64
+
65
+ ```bash
66
+ # 初始化設定
67
+ npx @cablate/banini-tracker init \
68
+ --apify-token YOUR_APIFY_TOKEN \
69
+ --tg-bot-token YOUR_TG_BOT_TOKEN \
70
+ --tg-channel-id YOUR_TG_CHANNEL_ID
71
+
72
+ # 抓取 Facebook 最新 3 篇
73
+ npx @cablate/banini-tracker fetch -s fb -n 3 --mark-seen
74
+
75
+ # 推送結果到 Telegram
76
+ npx @cablate/banini-tracker push -m "分析結果..."
77
+ ```
78
+
79
+ ### CLI 指令
80
+
81
+ | 指令 | 說明 |
82
+ |------|------|
83
+ | `init` | 初始化設定檔(`~/.banini-tracker.json`) |
84
+ | `config` | 顯示目前設定 |
85
+ | `fetch` | 抓取貼文,輸出 JSON 到 stdout |
86
+ | `push` | 推送訊息到 Telegram |
87
+ | `seen list` | 列出已讀貼文 ID |
88
+ | `seen mark <id...>` | 標記貼文為已讀 |
89
+ | `seen clear` | 清空已讀紀錄 |
90
+
91
+ ### fetch 選項
92
+
93
+ ```
94
+ -s, --source <source> 來源:fb(預設 fb)
95
+ -n, --limit <n> 每個來源抓幾篇(預設 3)
96
+ --no-dedup 不去重
97
+ --mark-seen 輸出後自動標記已讀
98
+ ```
99
+
100
+ ### push 選項
101
+
102
+ ```
103
+ -m, --message <text> 直接帶訊息
104
+ -f, --file <path> 從檔案讀取
105
+ --parse-mode <mode> HTML / Markdown / none(預設 HTML)
106
+ ```
107
+
108
+ 不帶 `-m` `-f` 時從 stdin 讀取。
109
+
110
+ ### 搭配 Claude Code 使用
111
+
112
+ 在 Claude Code 的 skill 中,Claude 自己就是分析引擎:
113
+
114
+ 1. `fetch` 抓貼文 → Claude 讀 JSON
115
+ 2. Claude 分析 + WebSearch 查最新走勢
116
+ 3. Claude 組報告 → `push` 推送 Telegram
117
+
118
+ 詳見 [`skill/SKILL.md`](skill/SKILL.md)。
119
+
120
+ ## 費用估算
121
+
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
+ ## 免責聲明
133
+
134
+ 本專案僅供娛樂參考,不構成任何投資建議。
135
+
136
+ ## License
137
+
138
+ MIT
package/dist/analyze.js CHANGED
@@ -1,74 +1,74 @@
1
1
  import OpenAI from 'openai';
2
- const SYSTEM_PROMPT = `你是一位台股投資分析助手,專門解讀「反指標女神」巴逆逆(8zz)的社群貼文。
3
-
4
- ## 背景
5
- 巴逆逆是台灣知名的「股海冥燈」,她的投資判斷長期被網友驗證與市場走勢高度反向。
6
- 買什麼跌什麼,賣什麼漲什麼,空什麼就飆漲。
7
-
8
- ## 時序意識(重要)
9
- - 使用者會告訴你「現在時間」,請以此為基準判斷時效性
10
- - 每篇貼文都有具體發文時間,請注意先後順序——她的想法可能在幾小時內改變
11
- - **當天的貼文最重要**,前幾天的貼文參考價值遞減
12
- - 如果她先說要買 A,後來又說停損 A,以最新的為準
13
-
14
- ## 反指標核心邏輯(務必遵守)
15
-
16
- 關鍵區分:她的操作狀態不同,反指標方向也不同。
17
-
18
- | 她的狀態 | 反指標解讀 | 原因 |
19
- |---------|-----------|------|
20
- | **買入/加碼** | 該標的可能下跌 | 她買什麼跌什麼 |
21
- | **持有中/被套(還沒賣)** | 該標的可能繼續跌 | 她還沒認輸,底部還沒到 |
22
- | **停損/賣出** | 該標的可能反彈上漲 | 她認輸出場 = 底部訊號 |
23
- | **看多/喊買** | 該標的可能下跌 | 她看好的通常會跌 |
24
- | **看空/喊賣** | 該標的可能上漲 | 她看衰的通常會漲 |
25
- | **空單/買 put** | 該標的可能飆漲 | 她空什麼就漲什麼 |
26
-
27
- **特別注意「被套」vs「停損」**:
28
- - 被套 = 她還抱著,還在賠錢 → 反指標:可能繼續跌(她還沒認輸)
29
- - 停損 = 她認賠賣出了 → 反指標:可能反彈(她一賣就漲)
30
- - 這兩個方向完全相反,不要搞混!
31
-
32
- **嚴禁腦補操作**:
33
- - 只根據貼文明確提到的操作判斷,不要自行推測
34
- - 「停損」= 她之前買了(做多),現在賣掉認賠。不是「放空」!
35
- - 「停損後漲停」= 她賣了之後股票漲了(反指標應驗),不是「放空被軋」
36
- - herAction 欄位只能填貼文中明確提到的操作,不能推測或腦補
37
-
38
- ## 分析流程
39
- 1. **辨識標的**:她提到了哪些股票、產業、原物料、ETF?
40
- - name 欄位必須用正式名稱(如「信驊」「鈦昇」「旺宏」),不要用她的暱稱(如「王」「渣男」)
41
- - 她說的「王」「大佬」「股王」可能是指台積電或信驊,請根據上下文判斷是哪檔股票,用正式名稱填入
42
- 2. **判斷她的操作狀態**:對照上方表格,確認她是買入、被套(還在持有)、還是停損賣出?
43
- 3. **反轉推導**:根據上方表格反轉。說清楚「對什麼看多/看空」以及「為什麼」
44
- 4. **連鎖效應**:反轉後會影響哪些相關板塊?要具體講出影響鏈
45
- - 例:她停損鈦昇 → 鈦昇可能反彈 → IC 設計族群連動上漲
46
- - 例:她買油正二被套還沒賣 → 油價可能繼續跌 → 原物料成本降 → 製造業利多 → 電子代工股受惠
47
- - 例:她停損賣出油正二 → 油價可能反彈 → 原物料成本升 → 通膨壓力回來
48
- 5. **信心評估**:她語氣越篤定、越興奮,反指標信號越強;越崩潰、越後悔,通常代表趨勢即將反轉
49
-
50
- ## 輸出格式(JSON)
51
- 所有欄位必須用繁體中文,不要用英文術語。
52
-
53
- {
54
- "hasInvestmentContent": true/false,
55
- "mentionedTargets": [
56
- {
57
- "name": "標的名稱(如:旺宏、鈦昇、原油正二)",
58
- "type": "個股 | 產業 | 原物料 | ETF | 指數",
59
- "herAction": "她的操作(如:買入、停損賣出、被套、加碼、看多、看空)",
60
- "reverseView": "反指標觀點(如:可能上漲、可能下跌、可能反彈、可能續跌)",
61
- "confidence": "高 | 中 | 低",
62
- "reasoning": "一句話解釋為什麼(如:她停損賣出通常是底部訊號)"
63
- }
64
- ],
65
- "chainAnalysis": "連鎖效應推導,講清楚 A 漲/跌 → 影響 B → 影響 C(2-3句)",
66
- "actionableSuggestion": "可操作的建議方向(1-2句,用中文講)",
67
- "moodScore": 1-10,
68
- "summary": "一句話摘要,適合推送通知,用直白中文"
69
- }
70
-
71
- 如果貼文與投資完全無關(純生活、搞笑),hasInvestmentContent 設為 false,其他欄位可省略,只需 summary。
2
+ const SYSTEM_PROMPT = `你是一位台股投資分析助手,專門解讀「反指標女神」巴逆逆(8zz)的社群貼文。
3
+
4
+ ## 背景
5
+ 巴逆逆是台灣知名的「股海冥燈」,她的投資判斷長期被網友驗證與市場走勢高度反向。
6
+ 買什麼跌什麼,賣什麼漲什麼,空什麼就飆漲。
7
+
8
+ ## 時序意識(重要)
9
+ - 使用者會告訴你「現在時間」,請以此為基準判斷時效性
10
+ - 每篇貼文都有具體發文時間,請注意先後順序——她的想法可能在幾小時內改變
11
+ - **當天的貼文最重要**,前幾天的貼文參考價值遞減
12
+ - 如果她先說要買 A,後來又說停損 A,以最新的為準
13
+
14
+ ## 反指標核心邏輯(務必遵守)
15
+
16
+ 關鍵區分:她的操作狀態不同,反指標方向也不同。
17
+
18
+ | 她的狀態 | 反指標解讀 | 原因 |
19
+ |---------|-----------|------|
20
+ | **買入/加碼** | 該標的可能下跌 | 她買什麼跌什麼 |
21
+ | **持有中/被套(還沒賣)** | 該標的可能繼續跌 | 她還沒認輸,底部還沒到 |
22
+ | **停損/賣出** | 該標的可能反彈上漲 | 她認輸出場 = 底部訊號 |
23
+ | **看多/喊買** | 該標的可能下跌 | 她看好的通常會跌 |
24
+ | **看空/喊賣** | 該標的可能上漲 | 她看衰的通常會漲 |
25
+ | **空單/買 put** | 該標的可能飆漲 | 她空什麼就漲什麼 |
26
+
27
+ **特別注意「被套」vs「停損」**:
28
+ - 被套 = 她還抱著,還在賠錢 → 反指標:可能繼續跌(她還沒認輸)
29
+ - 停損 = 她認賠賣出了 → 反指標:可能反彈(她一賣就漲)
30
+ - 這兩個方向完全相反,不要搞混!
31
+
32
+ **嚴禁腦補操作**:
33
+ - 只根據貼文明確提到的操作判斷,不要自行推測
34
+ - 「停損」= 她之前買了(做多),現在賣掉認賠。不是「放空」!
35
+ - 「停損後漲停」= 她賣了之後股票漲了(反指標應驗),不是「放空被軋」
36
+ - herAction 欄位只能填貼文中明確提到的操作,不能推測或腦補
37
+
38
+ ## 分析流程
39
+ 1. **辨識標的**:她提到了哪些股票、產業、原物料、ETF?
40
+ - name 欄位必須用正式名稱(如「信驊」「鈦昇」「旺宏」),不要用她的暱稱(如「王」「渣男」)
41
+ - 她說的「王」「大佬」「股王」可能是指台積電或信驊,請根據上下文判斷是哪檔股票,用正式名稱填入
42
+ 2. **判斷她的操作狀態**:對照上方表格,確認她是買入、被套(還在持有)、還是停損賣出?
43
+ 3. **反轉推導**:根據上方表格反轉。說清楚「對什麼看多/看空」以及「為什麼」
44
+ 4. **連鎖效應**:反轉後會影響哪些相關板塊?要具體講出影響鏈
45
+ - 例:她停損鈦昇 → 鈦昇可能反彈 → IC 設計族群連動上漲
46
+ - 例:她買油正二被套還沒賣 → 油價可能繼續跌 → 原物料成本降 → 製造業利多 → 電子代工股受惠
47
+ - 例:她停損賣出油正二 → 油價可能反彈 → 原物料成本升 → 通膨壓力回來
48
+ 5. **信心評估**:她語氣越篤定、越興奮,反指標信號越強;越崩潰、越後悔,通常代表趨勢即將反轉
49
+
50
+ ## 輸出格式(JSON)
51
+ 所有欄位必須用繁體中文,不要用英文術語。
52
+
53
+ {
54
+ "hasInvestmentContent": true/false,
55
+ "mentionedTargets": [
56
+ {
57
+ "name": "標的名稱(如:旺宏、鈦昇、原油正二)",
58
+ "type": "個股 | 產業 | 原物料 | ETF | 指數",
59
+ "herAction": "她的操作(如:買入、停損賣出、被套、加碼、看多、看空)",
60
+ "reverseView": "反指標觀點(如:可能上漲、可能下跌、可能反彈、可能續跌)",
61
+ "confidence": "高 | 中 | 低",
62
+ "reasoning": "一句話解釋為什麼(如:她停損賣出通常是底部訊號)"
63
+ }
64
+ ],
65
+ "chainAnalysis": "連鎖效應推導,講清楚 A 漲/跌 → 影響 B → 影響 C(2-3句)",
66
+ "actionableSuggestion": "可操作的建議方向(1-2句,用中文講)",
67
+ "moodScore": 1-10,
68
+ "summary": "一句話摘要,適合推送通知,用直白中文"
69
+ }
70
+
71
+ 如果貼文與投資完全無關(純生活、搞笑),hasInvestmentContent 設為 false,其他欄位可省略,只需 summary。
72
72
  注意:僅供娛樂參考,不構成投資建議。`;
73
73
  export async function analyzePosts(posts, llmConfig) {
74
74
  const client = new OpenAI({
@@ -83,11 +83,11 @@ export async function analyzePosts(posts, llmConfig) {
83
83
  return `### 貼文 ${i + 1} ${tag}(${p.timestamp})\n${p.text}`;
84
84
  })
85
85
  .join('\n\n');
86
- const userPrompt = `現在時間:${nowStr}(台北時間)
87
-
88
- 以下是巴逆逆最新的社群貼文(按時間從新到舊排列),請進行反指標分析。
89
- 注意:標記「今天」的貼文最重要,請優先分析。
90
-
86
+ const userPrompt = `現在時間:${nowStr}(台北時間)
87
+
88
+ 以下是巴逆逆最新的社群貼文(按時間從新到舊排列),請進行反指標分析。
89
+ 注意:標記「今天」的貼文最重要,請優先分析。
90
+
91
91
  ${formatted}`;
92
92
  console.log('[AI] 開始反指標分析...');
93
93
  const res = await client.chat.completions.create({
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,23 +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
- import { sendTelegramMessageWithConfig, formatReport } from './telegram.js';
16
+ import { sendTelegramMessageWithConfig, formatReport, formatFallbackReport } from './telegram.js';
18
17
  import { filterNewPosts as filterNew, markPostsSeen } from './seen.js';
18
+ import { withRetry } from './retry.js';
19
19
  // ── Config ──────────────────────────────────────────────────
20
- const THREADS_USERNAME = 'banini31';
21
20
  const FB_PAGE_URL = 'https://www.facebook.com/DieWithoutBang/';
22
21
  const DATA_DIR = join(process.cwd(), 'data');
23
22
  const isCronMode = process.argv.includes('--cron');
@@ -27,20 +26,6 @@ function env(key, fallback) {
27
26
  throw new Error(`Missing env: ${key}`);
28
27
  return val;
29
28
  }
30
- function fromThreads(p) {
31
- return {
32
- id: p.id,
33
- source: 'threads',
34
- text: p.text,
35
- timestamp: p.timestamp,
36
- likeCount: p.likeCount,
37
- replyCount: p.replyCount,
38
- url: p.url,
39
- mediaType: p.mediaType,
40
- mediaUrl: p.mediaUrl,
41
- ocrText: p.ocrText,
42
- };
43
- }
44
29
  function fromFacebook(p) {
45
30
  return {
46
31
  id: p.id,
@@ -75,25 +60,13 @@ async function runInner(opts) {
75
60
  console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`);
76
61
  const apifyToken = env('APIFY_TOKEN');
77
62
  const allPosts = [];
78
- // 1. 抓取 Threads
79
- if (!opts.fbOnly) {
80
- try {
81
- const threadsPosts = await fetchThreadsPosts(THREADS_USERNAME, apifyToken, opts.maxPosts);
82
- allPosts.push(...threadsPosts.map(fromThreads));
83
- }
84
- catch (err) {
85
- console.error(`[Threads] 抓取失敗: ${err instanceof Error ? err.message : err}`);
86
- }
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));
87
67
  }
88
- // 2. 抓取 Facebook
89
- if (!opts.threadsOnly) {
90
- try {
91
- const fbPosts = await fetchFacebookPosts(FB_PAGE_URL, apifyToken, opts.maxPosts);
92
- allPosts.push(...fbPosts.map(fromFacebook));
93
- }
94
- catch (err) {
95
- console.error(`[Facebook] 抓取失敗: ${err instanceof Error ? err.message : err}`);
96
- }
68
+ catch (err) {
69
+ console.error(`[Facebook] 抓取失敗: ${err instanceof Error ? err.message : err}`);
97
70
  }
98
71
  if (allPosts.length === 0) {
99
72
  console.log('沒有抓到任何貼文,結束');
@@ -113,14 +86,13 @@ async function runInner(opts) {
113
86
  const postDate = new Date(ts).toLocaleDateString('en-CA', { timeZone: 'Asia/Taipei' });
114
87
  return postDate === todayStr;
115
88
  };
116
- const threadCount = newPosts.filter((p) => p.source === 'threads').length;
117
- const fbCount = newPosts.filter((p) => p.source === 'facebook').length;
89
+ const fbCount = newPosts.length;
118
90
  const todayCount = newPosts.filter((p) => isToday(p.timestamp)).length;
119
- console.log(`發現 ${newPosts.length} 篇新貼文(Threads: ${threadCount}, FB: ${fbCount}, 今日: ${todayCount})\n`);
91
+ console.log(`發現 ${newPosts.length} 篇新貼文(FB: ${fbCount}, 今日: ${todayCount})\n`);
120
92
  markPostsSeen(newPosts.map((p) => p.id));
121
93
  // 4. 印出貼文
122
94
  for (const p of newPosts) {
123
- const tag = p.source === 'threads' ? 'TH' : 'FB';
95
+ const tag = 'FB';
124
96
  const todayTag = isToday(p.timestamp) ? ' [今天]' : '';
125
97
  const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
126
98
  console.log(`--- [${tag}]${todayTag} ${localTime} [${p.mediaType}] ---`);
@@ -137,7 +109,7 @@ async function runInner(opts) {
137
109
  const textsForAnalysis = newPosts
138
110
  .filter((p) => p.text.trim().length > 0 || p.ocrText.trim().length > 0)
139
111
  .map((p) => {
140
- const tag = p.source === 'threads' ? 'Threads' : 'Facebook';
112
+ const tag = 'Facebook';
141
113
  const localTime = new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' });
142
114
  let content = `[${tag}] ${p.text}`;
143
115
  if (p.ocrText)
@@ -148,11 +120,20 @@ async function runInner(opts) {
148
120
  console.log('所有新貼文都是純圖片,跳過分析');
149
121
  return;
150
122
  }
151
- const analysis = await analyzePosts(textsForAnalysis, {
152
- baseUrl: env('LLM_BASE_URL', 'https://api.deepinfra.com/v1/openai'),
153
- apiKey: env('LLM_API_KEY'),
154
- model: env('LLM_MODEL', 'MiniMaxAI/MiniMax-M2.5'),
155
- });
123
+ let analysis;
124
+ let llmFailed = false;
125
+ try {
126
+ analysis = await withRetry(() => analyzePosts(textsForAnalysis, {
127
+ baseUrl: env('LLM_BASE_URL', 'https://api.deepinfra.com/v1/openai'),
128
+ apiKey: env('LLM_API_KEY'),
129
+ model: env('LLM_MODEL', 'MiniMaxAI/MiniMax-M2.5'),
130
+ }), { label: 'LLM', maxRetries: 3, baseDelayMs: 5000 });
131
+ }
132
+ catch (err) {
133
+ console.error(`[LLM] 分析失敗,將推送純貼文摘要: ${err instanceof Error ? err.message : err}`);
134
+ llmFailed = true;
135
+ analysis = { hasInvestmentContent: false, summary: '(LLM 分析失敗,以下為原始貼文)' };
136
+ }
156
137
  // 6. 輸出結果
157
138
  console.log('========================================');
158
139
  console.log(' 巴逆逆反指標分析報告');
@@ -190,13 +171,16 @@ async function runInner(opts) {
190
171
  timestamp: new Date(p.timestamp).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }),
191
172
  isToday: isToday(p.timestamp),
192
173
  text: p.text.slice(0, 60),
174
+ url: p.url,
193
175
  }));
194
- const msg = formatReport(analysis, { threads: threadCount, fb: fbCount }, postSummaries);
195
- await sendTelegramMessageWithConfig({ botToken: tgToken, channelId: tgChannelId }, msg);
176
+ const msg = llmFailed
177
+ ? formatFallbackReport(postSummaries)
178
+ : formatReport(analysis, { fb: fbCount }, postSummaries);
179
+ await withRetry(() => sendTelegramMessageWithConfig({ botToken: tgToken, channelId: tgChannelId }, msg), { label: 'Telegram', maxRetries: 3, baseDelayMs: 3000 });
196
180
  console.log('[Telegram] 通知已發送');
197
181
  }
198
182
  catch (err) {
199
- console.error(`[Telegram] 發送失敗: ${err instanceof Error ? err.message : err}`);
183
+ console.error(`[Telegram] 發送失敗(已重試 3 次): ${err instanceof Error ? err.message : err}`);
200
184
  }
201
185
  }
202
186
  else {
@@ -213,31 +197,29 @@ if (isCronMode) {
213
197
  // 盤中:週一到五 09:00-13:30,每 30 分鐘,FB only 抓 1 篇
214
198
  // cron 不支援半小時結束,用 9:00-13:00 每 30 分 + 13:30 單獨一個
215
199
  cron.schedule('7,37 9-12 * * 1-5', () => {
216
- run({ fbOnly: true, threadsOnly: false, maxPosts: 1, isDryRun: false, label: '盤中' })
200
+ run({ maxPosts: 1, isDryRun: false, label: '盤中' })
217
201
  .catch((err) => console.error('[盤中] 執行失敗:', err));
218
202
  }, { timezone: 'Asia/Taipei' });
219
203
  cron.schedule('7 13 * * 1-5', () => {
220
- run({ fbOnly: true, threadsOnly: false, maxPosts: 1, isDryRun: false, label: '盤中' })
204
+ run({ maxPosts: 1, isDryRun: false, label: '盤中' })
221
205
  .catch((err) => console.error('[盤中] 執行失敗:', err));
222
206
  }, { timezone: 'Asia/Taipei' });
223
- // 盤後:每天晚上 23:00,Threads + FB 3 篇
207
+ // 盤後:每天晚上 23:00,FB 3 篇
224
208
  cron.schedule('3 23 * * *', () => {
225
- run({ fbOnly: false, threadsOnly: false, maxPosts: 3, isDryRun: false, label: '盤後' })
209
+ run({ maxPosts: 3, isDryRun: false, label: '盤後' })
226
210
  .catch((err) => console.error('[盤後] 執行失敗:', err));
227
211
  }, { timezone: 'Asia/Taipei' });
228
212
  console.log('=== 巴逆逆排程已啟動 ===');
229
- console.log(' 盤中:週一~五 09:07/09:37/10:07/.../13:07(FB only, 1 篇)');
230
- 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 篇)');
231
215
  console.log(' 按 Ctrl+C 停止\n');
232
216
  }
233
217
  else {
234
218
  // 單次執行模式
235
219
  const isDryRun = process.argv.includes('--dry');
236
- const threadsOnly = process.argv.includes('--threads-only');
237
- const fbOnly = process.argv.includes('--fb-only');
238
220
  const maxPostsArg = process.argv.find((a) => a.startsWith('--max-posts='));
239
221
  const maxPosts = maxPostsArg ? parseInt(maxPostsArg.split('=')[1], 10) : 3;
240
- run({ fbOnly, threadsOnly, maxPosts, isDryRun, label: '手動' }).catch((err) => {
222
+ run({ maxPosts, isDryRun, label: '手動' }).catch((err) => {
241
223
  console.error('執行失敗:', err);
242
224
  process.exit(1);
243
225
  });
@@ -0,0 +1,6 @@
1
+ export interface RetryOptions {
2
+ maxRetries?: number;
3
+ baseDelayMs?: number;
4
+ label?: string;
5
+ }
6
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
package/dist/retry.js ADDED
@@ -0,0 +1,22 @@
1
+ export async function withRetry(fn, opts = {}) {
2
+ const { maxRetries = 3, baseDelayMs = 3000, label = '' } = opts;
3
+ let lastError;
4
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
5
+ try {
6
+ return await fn();
7
+ }
8
+ catch (err) {
9
+ lastError = err;
10
+ const msg = err instanceof Error ? err.message : String(err);
11
+ if (attempt < maxRetries) {
12
+ const delay = baseDelayMs * attempt;
13
+ console.error(`[${label}] 第 ${attempt} 次失敗: ${msg},${delay / 1000}s 後重試...`);
14
+ await new Promise((r) => setTimeout(r, delay));
15
+ }
16
+ else {
17
+ console.error(`[${label}] 第 ${attempt} 次失敗: ${msg},已達重試上限`);
18
+ }
19
+ }
20
+ }
21
+ throw lastError;
22
+ }
@@ -5,10 +5,11 @@ 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;
12
+ url: string;
12
13
  }
13
14
  export declare function formatReport(analysis: {
14
15
  summary: string;
@@ -25,7 +26,7 @@ export declare function formatReport(analysis: {
25
26
  actionableSuggestion?: string;
26
27
  moodScore?: number;
27
28
  }, postCount: {
28
- threads: number;
29
29
  fb: number;
30
30
  }, posts: PostSummary[]): string;
31
+ export declare function formatFallbackReport(posts: PostSummary[]): string;
31
32
  export {};
package/dist/telegram.js CHANGED
@@ -28,14 +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
- lines.push(`${src}${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}`);
37
+ const link = p.url ? ` <a href="${p.url}">原文</a>` : '';
38
+ lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 50 ? '…' : ''}${link}`);
39
39
  }
40
40
  lines.push('');
41
41
  lines.push(escapeHtml(analysis.summary));
@@ -73,3 +73,16 @@ export function formatReport(analysis, postCount, posts) {
73
73
  lines.push('\n<i>僅供娛樂參考,不構成投資建議</i>');
74
74
  return lines.join('\n');
75
75
  }
76
+ export function formatFallbackReport(posts) {
77
+ const lines = [];
78
+ lines.push('<b>巴逆逆貼文速報</b>(LLM 分析失敗)');
79
+ lines.push('');
80
+ for (const p of posts) {
81
+ const todayTag = p.isToday ? ' [今天]' : '';
82
+ const preview = escapeHtml(p.text.replace(/\n/g, ' ').slice(0, 80));
83
+ const link = p.url ? ` <a href="${p.url}">原文</a>` : '';
84
+ lines.push(`FB${todayTag} ${p.timestamp}|${preview}${p.text.length > 80 ? '…' : ''}${link}`);
85
+ }
86
+ lines.push('\n<i>LLM 服務暫時無法使用,僅列出原始貼文</i>');
87
+ return lines.join('\n');
88
+ }
package/package.json CHANGED
@@ -1,38 +1,46 @@
1
- {
2
- "name": "@cablate/banini-tracker",
3
- "version": "2.0.0",
4
- "description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
5
- "type": "module",
6
- "bin": {
7
- "banini-tracker": "dist/cli.js"
8
- },
9
- "scripts": {
10
- "dev": "tsx src/index.ts",
11
- "dry": "tsx src/index.ts --dry",
12
- "market": "tsx src/index.ts --fb-only --max-posts=1",
13
- "evening": "tsx src/index.ts --max-posts=3",
14
- "cron": "tsx src/index.ts --cron",
15
- "cli": "tsx src/cli.ts",
16
- "build": "tsc",
17
- "start": "node dist/index.js --cron",
18
- "prepublishOnly": "npm run build"
19
- },
20
- "keywords": ["banini", "reverse-indicator", "threads", "facebook", "apify", "telegram", "claude"],
21
- "author": "cablate",
22
- "license": "MIT",
23
- "dependencies": {
24
- "commander": "^13.0.0",
25
- "dotenv": "^16.4.0",
26
- "node-cron": "^4.2.1",
27
- "openai": "^4.0.0"
28
- },
29
- "devDependencies": {
30
- "@types/node": "^20.0.0",
31
- "@types/node-cron": "^3.0.11",
32
- "tsx": "^4.0.0",
33
- "typescript": "^5.5.0"
34
- },
35
- "files": [
36
- "dist"
37
- ]
38
- }
1
+ {
2
+ "name": "@cablate/banini-tracker",
3
+ "version": "2.0.2",
4
+ "description": "巴逆逆反指標追蹤器 — 常駐排程 + CLI 雙模式",
5
+ "type": "module",
6
+ "bin": {
7
+ "banini-tracker": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx src/index.ts",
11
+ "dry": "tsx src/index.ts --dry",
12
+ "market": "tsx src/index.ts --fb-only --max-posts=1",
13
+ "evening": "tsx src/index.ts --max-posts=3",
14
+ "cron": "tsx src/index.ts --cron",
15
+ "cli": "tsx src/cli.ts",
16
+ "build": "tsc",
17
+ "start": "node dist/index.js --cron",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "banini",
22
+ "reverse-indicator",
23
+ "threads",
24
+ "facebook",
25
+ "apify",
26
+ "telegram",
27
+ "claude"
28
+ ],
29
+ "author": "cablate",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "commander": "^13.0.0",
33
+ "dotenv": "^16.4.0",
34
+ "node-cron": "^4.2.1",
35
+ "openai": "^4.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.0.0",
39
+ "@types/node-cron": "^3.0.11",
40
+ "tsx": "^4.0.0",
41
+ "typescript": "^5.5.0"
42
+ },
43
+ "files": [
44
+ "dist"
45
+ ]
46
+ }
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
- }