opentradex 0.1.0
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/.env.example +8 -0
- package/CLAUDE.md +98 -0
- package/README.md +246 -0
- package/SOUL.md +79 -0
- package/SPEC.md +317 -0
- package/SUBMISSION.md +30 -0
- package/architecture.excalidraw +170 -0
- package/architecture.png +0 -0
- package/bin/opentradex.mjs +4 -0
- package/data/.gitkeep +0 -0
- package/data/strategy_notes.md +158 -0
- package/gossip/__init__.py +0 -0
- package/gossip/dashboard.py +150 -0
- package/gossip/db.py +358 -0
- package/gossip/kalshi.py +492 -0
- package/gossip/news.py +235 -0
- package/gossip/trader.py +646 -0
- package/main.py +287 -0
- package/package.json +47 -0
- package/requirements.txt +7 -0
- package/src/cli.mjs +124 -0
- package/src/index.mjs +420 -0
- package/web/AGENTS.md +5 -0
- package/web/CLAUDE.md +1 -0
- package/web/README.md +36 -0
- package/web/components.json +25 -0
- package/web/eslint.config.mjs +18 -0
- package/web/next.config.ts +7 -0
- package/web/package-lock.json +11626 -0
- package/web/package.json +37 -0
- package/web/postcss.config.mjs +7 -0
- package/web/public/file.svg +1 -0
- package/web/public/globe.svg +1 -0
- package/web/public/next.svg +1 -0
- package/web/public/vercel.svg +1 -0
- package/web/public/window.svg +1 -0
- package/web/src/app/api/agent/route.ts +77 -0
- package/web/src/app/api/agent/stream/route.ts +87 -0
- package/web/src/app/api/markets/route.ts +15 -0
- package/web/src/app/api/news/live/route.ts +77 -0
- package/web/src/app/api/news/reddit/route.ts +118 -0
- package/web/src/app/api/news/route.ts +10 -0
- package/web/src/app/api/news/tiktok/route.ts +115 -0
- package/web/src/app/api/news/truthsocial/route.ts +116 -0
- package/web/src/app/api/news/twitter/route.ts +186 -0
- package/web/src/app/api/portfolio/route.ts +50 -0
- package/web/src/app/api/prices/route.ts +18 -0
- package/web/src/app/api/trades/route.ts +10 -0
- package/web/src/app/favicon.ico +0 -0
- package/web/src/app/globals.css +170 -0
- package/web/src/app/layout.tsx +36 -0
- package/web/src/app/page.tsx +366 -0
- package/web/src/components/AgentLog.tsx +71 -0
- package/web/src/components/LiveStream.tsx +394 -0
- package/web/src/components/MarketScanner.tsx +111 -0
- package/web/src/components/NewsFeed.tsx +561 -0
- package/web/src/components/PortfolioStrip.tsx +139 -0
- package/web/src/components/PositionsPanel.tsx +219 -0
- package/web/src/components/TopBar.tsx +127 -0
- package/web/src/components/ui/badge.tsx +52 -0
- package/web/src/components/ui/button.tsx +60 -0
- package/web/src/components/ui/card.tsx +103 -0
- package/web/src/components/ui/scroll-area.tsx +55 -0
- package/web/src/components/ui/separator.tsx +25 -0
- package/web/src/components/ui/tabs.tsx +82 -0
- package/web/src/components/ui/tooltip.tsx +66 -0
- package/web/src/lib/db.ts +81 -0
- package/web/src/lib/types.ts +130 -0
- package/web/src/lib/utils.ts +6 -0
- package/web/tsconfig.json +34 -0
package/web/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "eslint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@base-ui/react": "^1.3.0",
|
|
13
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
14
|
+
"better-sqlite3": "^12.8.0",
|
|
15
|
+
"class-variance-authority": "^0.7.1",
|
|
16
|
+
"clsx": "^2.1.1",
|
|
17
|
+
"lucide-react": "^1.7.0",
|
|
18
|
+
"next": "16.2.2",
|
|
19
|
+
"react": "19.2.4",
|
|
20
|
+
"react-dom": "19.2.4",
|
|
21
|
+
"react-markdown": "^10.1.0",
|
|
22
|
+
"remark-gfm": "^4.0.1",
|
|
23
|
+
"shadcn": "^4.1.2",
|
|
24
|
+
"tailwind-merge": "^3.5.0",
|
|
25
|
+
"tw-animate-css": "^1.4.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@tailwindcss/postcss": "^4",
|
|
29
|
+
"@types/node": "^20",
|
|
30
|
+
"@types/react": "^19",
|
|
31
|
+
"@types/react-dom": "^19",
|
|
32
|
+
"eslint": "^9",
|
|
33
|
+
"eslint-config-next": "16.2.2",
|
|
34
|
+
"tailwindcss": "^4",
|
|
35
|
+
"typescript": "^5"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const db = getDb();
|
|
8
|
+
const cycles = db
|
|
9
|
+
.prepare("SELECT * FROM agent_logs ORDER BY timestamp DESC LIMIT 20")
|
|
10
|
+
.all();
|
|
11
|
+
return NextResponse.json(cycles);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
const body = await request.json();
|
|
16
|
+
const { action, prompt, rationale, interval } = body;
|
|
17
|
+
|
|
18
|
+
const projectDir = path.join(process.cwd(), "..");
|
|
19
|
+
|
|
20
|
+
if (action === "run_cycle") {
|
|
21
|
+
const args = ["main.py"];
|
|
22
|
+
if (prompt) args.push("--prompt", prompt);
|
|
23
|
+
if (rationale) args.push("--rationale", rationale);
|
|
24
|
+
|
|
25
|
+
const child = spawn("python3", args, {
|
|
26
|
+
cwd: projectDir,
|
|
27
|
+
detached: true,
|
|
28
|
+
stdio: "ignore",
|
|
29
|
+
});
|
|
30
|
+
child.unref();
|
|
31
|
+
|
|
32
|
+
return NextResponse.json({
|
|
33
|
+
status: "spawned",
|
|
34
|
+
pid: child.pid,
|
|
35
|
+
message: "Agent cycle started in background",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (action === "start_loop") {
|
|
40
|
+
const args = ["main.py", "--loop"];
|
|
41
|
+
if (interval) args.push("--interval", String(interval));
|
|
42
|
+
|
|
43
|
+
const child = spawn("python3", args, {
|
|
44
|
+
cwd: projectDir,
|
|
45
|
+
detached: true,
|
|
46
|
+
stdio: "ignore",
|
|
47
|
+
});
|
|
48
|
+
child.unref();
|
|
49
|
+
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
status: "loop_started",
|
|
52
|
+
pid: child.pid,
|
|
53
|
+
interval: interval || 900,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (action === "submit_rationale") {
|
|
58
|
+
if (!rationale) {
|
|
59
|
+
return NextResponse.json({ error: "rationale required" }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const child = spawn("python3", ["main.py", "--rationale", rationale], {
|
|
63
|
+
cwd: projectDir,
|
|
64
|
+
detached: true,
|
|
65
|
+
stdio: "ignore",
|
|
66
|
+
});
|
|
67
|
+
child.unref();
|
|
68
|
+
|
|
69
|
+
return NextResponse.json({
|
|
70
|
+
status: "rationale_submitted",
|
|
71
|
+
thesis: rationale,
|
|
72
|
+
message: "Agent is researching your thesis",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
|
77
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
const DATA_DIR = path.join(process.cwd(), "..", "data");
|
|
6
|
+
const LIVE_LOG = path.join(DATA_DIR, "agent_live.jsonl");
|
|
7
|
+
const STATUS_FILE = path.join(DATA_DIR, "agent_status.json");
|
|
8
|
+
|
|
9
|
+
export async function GET(request: Request) {
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
const offset = parseInt(url.searchParams.get("offset") || "0", 10);
|
|
12
|
+
|
|
13
|
+
// Read status
|
|
14
|
+
let status = { status: "idle" };
|
|
15
|
+
try {
|
|
16
|
+
if (fs.existsSync(STATUS_FILE)) {
|
|
17
|
+
status = JSON.parse(fs.readFileSync(STATUS_FILE, "utf-8"));
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Read live log from offset
|
|
24
|
+
const lines: object[] = [];
|
|
25
|
+
let newOffset = offset;
|
|
26
|
+
try {
|
|
27
|
+
if (fs.existsSync(LIVE_LOG)) {
|
|
28
|
+
const content = fs.readFileSync(LIVE_LOG, "utf-8");
|
|
29
|
+
const allLines = content.split("\n").filter(Boolean);
|
|
30
|
+
newOffset = allLines.length;
|
|
31
|
+
const newLines = allLines.slice(offset);
|
|
32
|
+
|
|
33
|
+
for (const line of newLines) {
|
|
34
|
+
try {
|
|
35
|
+
const msg = JSON.parse(line);
|
|
36
|
+
// Extract useful info for the frontend
|
|
37
|
+
if (msg.type === "assistant" && msg.message?.content) {
|
|
38
|
+
for (const block of msg.message.content) {
|
|
39
|
+
if (block.type === "text" && block.text) {
|
|
40
|
+
lines.push({ type: "text", text: block.text });
|
|
41
|
+
}
|
|
42
|
+
if (block.type === "tool_use") {
|
|
43
|
+
lines.push({
|
|
44
|
+
type: "tool_use",
|
|
45
|
+
tool: block.name,
|
|
46
|
+
input: typeof block.input === "string"
|
|
47
|
+
? block.input.slice(0, 500)
|
|
48
|
+
: JSON.stringify(block.input).slice(0, 500),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (msg.type === "tool_result" || (msg.type === "user" && msg.message?.content)) {
|
|
54
|
+
const content = msg.message?.content;
|
|
55
|
+
if (Array.isArray(content)) {
|
|
56
|
+
for (const block of content) {
|
|
57
|
+
if (block.type === "tool_result" && block.content) {
|
|
58
|
+
const text = typeof block.content === "string"
|
|
59
|
+
? block.content
|
|
60
|
+
: JSON.stringify(block.content);
|
|
61
|
+
lines.push({
|
|
62
|
+
type: "tool_result",
|
|
63
|
+
tool_use_id: block.tool_use_id,
|
|
64
|
+
text: text.slice(0, 2000),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (msg.type === "result") {
|
|
71
|
+
lines.push({ type: "result", result: msg.result?.slice(0, 5000) });
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// skip unparseable lines
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return NextResponse.json({
|
|
83
|
+
status,
|
|
84
|
+
lines,
|
|
85
|
+
offset: newOffset,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
const markets = db
|
|
7
|
+
.prepare(
|
|
8
|
+
`SELECT ms.* FROM market_snapshots ms
|
|
9
|
+
INNER JOIN (SELECT ticker, MAX(timestamp) as max_ts FROM market_snapshots GROUP BY ticker) latest
|
|
10
|
+
ON ms.ticker = latest.ticker AND ms.timestamp = latest.max_ts
|
|
11
|
+
ORDER BY ms.volume DESC LIMIT 50`
|
|
12
|
+
)
|
|
13
|
+
.all();
|
|
14
|
+
return NextResponse.json(markets);
|
|
15
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const NEWS_API_KEY = process.env.NEWS_API_KEY || "813a01019fad42ed9dadc7793c60c386";
|
|
4
|
+
|
|
5
|
+
interface NewsItem {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
source: string;
|
|
9
|
+
snippet: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
image: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const QUERIES = [
|
|
15
|
+
"prediction markets OR kalshi OR polymarket",
|
|
16
|
+
"tariffs OR trade war OR sanctions",
|
|
17
|
+
"federal reserve OR interest rates OR inflation",
|
|
18
|
+
"trump cabinet OR attorney general OR secretary",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
let cache: { items: NewsItem[]; fetchedAt: number } = { items: [], fetchedAt: 0 };
|
|
22
|
+
const CACHE_TTL = 120_000;
|
|
23
|
+
|
|
24
|
+
export async function GET() {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
if (cache.items.length > 0 && now - cache.fetchedAt < CACHE_TTL) {
|
|
27
|
+
return NextResponse.json(cache.items);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const allItems: NewsItem[] = [];
|
|
31
|
+
|
|
32
|
+
const fetches = QUERIES.map(async (q) => {
|
|
33
|
+
try {
|
|
34
|
+
const params = new URLSearchParams({
|
|
35
|
+
q,
|
|
36
|
+
language: "en",
|
|
37
|
+
sortBy: "publishedAt",
|
|
38
|
+
pageSize: "10",
|
|
39
|
+
apiKey: NEWS_API_KEY,
|
|
40
|
+
});
|
|
41
|
+
const res = await fetch(
|
|
42
|
+
`https://newsapi.org/v2/everything?${params}`,
|
|
43
|
+
{ signal: AbortSignal.timeout(8000) }
|
|
44
|
+
);
|
|
45
|
+
if (!res.ok) return [];
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
return (data.articles || []).map(
|
|
48
|
+
(a: Record<string, unknown>): NewsItem => ({
|
|
49
|
+
title: (a.title as string) || "",
|
|
50
|
+
url: (a.url as string) || "",
|
|
51
|
+
source: ((a.source as Record<string, string>)?.name as string) || "",
|
|
52
|
+
snippet: (a.description as string) || "",
|
|
53
|
+
timestamp: (a.publishedAt as string) || new Date().toISOString(),
|
|
54
|
+
image: (a.urlToImage as string) || null,
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const results = await Promise.all(fetches);
|
|
63
|
+
for (const items of results) {
|
|
64
|
+
allItems.push(...items);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
allItems.sort(
|
|
68
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const deduped = allItems.filter(
|
|
72
|
+
(item, i, arr) => item.title && arr.findIndex((x) => x.title === item.title) === i
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
cache = { items: deduped.slice(0, 50), fetchedAt: now };
|
|
76
|
+
return NextResponse.json(cache.items);
|
|
77
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const APIFY_TOKEN = process.env.APIFY_API_TOKEN || "";
|
|
4
|
+
|
|
5
|
+
interface RedditPost {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
author: string;
|
|
9
|
+
authorName: string;
|
|
10
|
+
authorImage?: string;
|
|
11
|
+
likes: number;
|
|
12
|
+
reposts: number;
|
|
13
|
+
replies?: number;
|
|
14
|
+
url: string;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
platform: "reddit";
|
|
17
|
+
images?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SUBREDDITS = [
|
|
21
|
+
"https://www.reddit.com/r/wallstreetbets/hot/",
|
|
22
|
+
"https://www.reddit.com/r/politics/hot/",
|
|
23
|
+
"https://www.reddit.com/r/news/hot/",
|
|
24
|
+
"https://www.reddit.com/r/worldnews/hot/",
|
|
25
|
+
"https://www.reddit.com/r/economics/hot/",
|
|
26
|
+
"https://www.reddit.com/r/stocks/hot/",
|
|
27
|
+
"https://www.reddit.com/r/polymarket/hot/",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
let cache: { items: RedditPost[]; fetchedAt: number } = { items: [], fetchedAt: 0 };
|
|
31
|
+
const CACHE_TTL = 5 * 60 * 60_000; // 5 hours
|
|
32
|
+
let pending: Promise<RedditPost[]> | null = null;
|
|
33
|
+
|
|
34
|
+
async function fetchRedditPosts(): Promise<RedditPost[]> {
|
|
35
|
+
if (!APIFY_TOKEN) {
|
|
36
|
+
console.error("[reddit] APIFY_API_TOKEN not set");
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log("[reddit] fetching posts from Apify...");
|
|
41
|
+
const res = await fetch(
|
|
42
|
+
`https://api.apify.com/v2/acts/trudax~reddit-scraper-lite/run-sync-get-dataset-items?token=${APIFY_TOKEN}&timeout=60`,
|
|
43
|
+
{
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
startUrls: SUBREDDITS.map((url) => ({ url })),
|
|
48
|
+
maxItems: 150,
|
|
49
|
+
}),
|
|
50
|
+
signal: AbortSignal.timeout(70_000),
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
console.error("[reddit] Apify response not ok:", res.status, await res.text().catch(() => ""));
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
if (!Array.isArray(data)) {
|
|
60
|
+
console.error("[reddit] unexpected response:", JSON.stringify(data).slice(0, 300));
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
console.log(`[reddit] got ${data.length} raw items`);
|
|
64
|
+
|
|
65
|
+
const posts: RedditPost[] = data
|
|
66
|
+
.filter((item: Record<string, unknown>) => item.dataType === "post" && item.title)
|
|
67
|
+
.map((item: Record<string, unknown>) => {
|
|
68
|
+
const images = item.imageUrls as string[] | undefined;
|
|
69
|
+
const thumbnail = item.thumbnailUrl as string | undefined;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: (item.id as string) || "",
|
|
73
|
+
text: (item.title as string) || "",
|
|
74
|
+
author: (item.username as string) || "",
|
|
75
|
+
authorName: (item.communityName as string) || (item.parsedCommunityName as string) || "",
|
|
76
|
+
authorImage: undefined,
|
|
77
|
+
likes: (item.upVotes as number) || 0,
|
|
78
|
+
reposts: 0,
|
|
79
|
+
replies: (item.numberOfComments as number) || 0,
|
|
80
|
+
url: (item.url as string) || "",
|
|
81
|
+
timestamp: (item.createdAt as string) || new Date().toISOString(),
|
|
82
|
+
platform: "reddit" as const,
|
|
83
|
+
images: images && images.length > 0
|
|
84
|
+
? images
|
|
85
|
+
: thumbnail && thumbnail.startsWith("http")
|
|
86
|
+
? [thumbnail]
|
|
87
|
+
: undefined,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
posts.sort(
|
|
92
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return posts.slice(0, 40);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function GET() {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
|
|
101
|
+
if (cache.items.length > 0 && now - cache.fetchedAt < CACHE_TTL) {
|
|
102
|
+
return NextResponse.json(cache.items);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!pending) {
|
|
106
|
+
pending = fetchRedditPosts().finally(() => { pending = null; });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const posts = await pending;
|
|
111
|
+
if (posts.length > 0) {
|
|
112
|
+
cache = { items: posts, fetchedAt: Date.now() };
|
|
113
|
+
}
|
|
114
|
+
return NextResponse.json(cache.items);
|
|
115
|
+
} catch {
|
|
116
|
+
return NextResponse.json(cache.items);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
const news = db
|
|
7
|
+
.prepare("SELECT * FROM news ORDER BY timestamp DESC LIMIT 100")
|
|
8
|
+
.all();
|
|
9
|
+
return NextResponse.json(news);
|
|
10
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const APIFY_TOKEN = process.env.APIFY_API_TOKEN || "";
|
|
4
|
+
|
|
5
|
+
interface TikTokPost {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
author: string;
|
|
9
|
+
authorName: string;
|
|
10
|
+
authorImage?: string;
|
|
11
|
+
likes: number;
|
|
12
|
+
reposts: number;
|
|
13
|
+
replies?: number;
|
|
14
|
+
url: string;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
platform: "tiktok";
|
|
17
|
+
images?: string[];
|
|
18
|
+
videoCover?: string;
|
|
19
|
+
videoDuration?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SEARCH_QUERIES = [
|
|
23
|
+
"kalshi polymarket prediction markets",
|
|
24
|
+
"tariffs trade war",
|
|
25
|
+
"trump executive order",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
let cache: { items: TikTokPost[]; fetchedAt: number } = { items: [], fetchedAt: 0 };
|
|
29
|
+
const CACHE_TTL = 5 * 60 * 60_000; // 5 hours
|
|
30
|
+
let pending: Promise<TikTokPost[]> | null = null;
|
|
31
|
+
|
|
32
|
+
async function fetchTikToks(): Promise<TikTokPost[]> {
|
|
33
|
+
if (!APIFY_TOKEN) {
|
|
34
|
+
console.error("[tiktok] APIFY_API_TOKEN not set");
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log("[tiktok] fetching videos from Apify...");
|
|
39
|
+
const res = await fetch(
|
|
40
|
+
`https://api.apify.com/v2/acts/clockworks~tiktok-scraper/run-sync-get-dataset-items?token=${APIFY_TOKEN}&timeout=120`,
|
|
41
|
+
{
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
searchQueries: SEARCH_QUERIES,
|
|
46
|
+
resultsPerPage: 10,
|
|
47
|
+
}),
|
|
48
|
+
signal: AbortSignal.timeout(130_000),
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
console.error("[tiktok] Apify response not ok:", res.status, await res.text().catch(() => ""));
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
if (!Array.isArray(data)) {
|
|
58
|
+
console.error("[tiktok] unexpected response:", JSON.stringify(data).slice(0, 300));
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
console.log(`[tiktok] got ${data.length} raw videos`);
|
|
62
|
+
|
|
63
|
+
const posts: TikTokPost[] = data
|
|
64
|
+
.filter((item: Record<string, unknown>) => item.text)
|
|
65
|
+
.map((item: Record<string, unknown>) => {
|
|
66
|
+
const author = item.authorMeta as Record<string, unknown> | undefined;
|
|
67
|
+
const videoMeta = item.videoMeta as Record<string, unknown> | undefined;
|
|
68
|
+
const coverUrl = (videoMeta?.coverUrl as string) || undefined;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: (item.id as string) || "",
|
|
72
|
+
text: (item.text as string) || "",
|
|
73
|
+
author: (author?.name as string) || "",
|
|
74
|
+
authorName: (author?.nickName as string) || (author?.name as string) || "",
|
|
75
|
+
authorImage: (author?.avatar as string) || undefined,
|
|
76
|
+
likes: (item.diggCount as number) || 0,
|
|
77
|
+
reposts: (item.shareCount as number) || 0,
|
|
78
|
+
replies: (item.commentCount as number) || 0,
|
|
79
|
+
url: (item.webVideoUrl as string) || "",
|
|
80
|
+
timestamp: (item.createTimeISO as string) || new Date().toISOString(),
|
|
81
|
+
platform: "tiktok" as const,
|
|
82
|
+
images: undefined,
|
|
83
|
+
videoCover: coverUrl,
|
|
84
|
+
videoDuration: (videoMeta?.duration as number) || undefined,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
posts.sort(
|
|
89
|
+
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return posts.slice(0, 30);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function GET() {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
|
|
98
|
+
if (cache.items.length > 0 && now - cache.fetchedAt < CACHE_TTL) {
|
|
99
|
+
return NextResponse.json(cache.items);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!pending) {
|
|
103
|
+
pending = fetchTikToks().finally(() => { pending = null; });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const posts = await pending;
|
|
108
|
+
if (posts.length > 0) {
|
|
109
|
+
cache = { items: posts, fetchedAt: Date.now() };
|
|
110
|
+
}
|
|
111
|
+
return NextResponse.json(cache.items);
|
|
112
|
+
} catch {
|
|
113
|
+
return NextResponse.json(cache.items);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const APIFY_TOKEN = process.env.APIFY_API_TOKEN || "";
|
|
4
|
+
|
|
5
|
+
interface TruthPost {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
author: string;
|
|
9
|
+
authorName: string;
|
|
10
|
+
authorImage?: string;
|
|
11
|
+
likes: number;
|
|
12
|
+
reposts: number;
|
|
13
|
+
replies?: number;
|
|
14
|
+
url: string;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
platform: "truthsocial";
|
|
17
|
+
images?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let cache: { items: TruthPost[]; fetchedAt: number } = {
|
|
21
|
+
items: [],
|
|
22
|
+
fetchedAt: 0,
|
|
23
|
+
};
|
|
24
|
+
const CACHE_TTL = 6 * 60 * 60_000; // 6 hours
|
|
25
|
+
let pending: Promise<TruthPost[]> | null = null;
|
|
26
|
+
|
|
27
|
+
function stripHtml(html: string): string {
|
|
28
|
+
return html
|
|
29
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
30
|
+
.replace(/<[^>]+>/g, "")
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, "'")
|
|
36
|
+
.replace(/’/g, "\u2019")
|
|
37
|
+
.replace(/“/g, "\u201C")
|
|
38
|
+
.replace(/”/g, "\u201D")
|
|
39
|
+
.replace(/–/g, "\u2013")
|
|
40
|
+
.replace(/—/g, "\u2014")
|
|
41
|
+
.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchTruthPosts(): Promise<TruthPost[]> {
|
|
45
|
+
if (!APIFY_TOKEN) return [];
|
|
46
|
+
|
|
47
|
+
const res = await fetch(
|
|
48
|
+
`https://api.apify.com/v2/acts/muhammetakkurtt~truth-social-scraper/run-sync-get-dataset-items?token=${APIFY_TOKEN}&timeout=60`,
|
|
49
|
+
{
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
username: "realDonaldTrump",
|
|
54
|
+
maxPosts: 25,
|
|
55
|
+
}),
|
|
56
|
+
signal: AbortSignal.timeout(70_000),
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (!res.ok) return [];
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
if (!Array.isArray(data)) return [];
|
|
63
|
+
|
|
64
|
+
return data
|
|
65
|
+
.filter((s: Record<string, unknown>) => s.visibility === "public")
|
|
66
|
+
.map((status: Record<string, unknown>) => {
|
|
67
|
+
const account = status.account as Record<string, unknown> | undefined;
|
|
68
|
+
const media = status.media_attachments as
|
|
69
|
+
| Array<Record<string, unknown>>
|
|
70
|
+
| undefined;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: (status.id as string) || "",
|
|
74
|
+
text: stripHtml((status.content as string) || ""),
|
|
75
|
+
author: (account?.username as string) || "realDonaldTrump",
|
|
76
|
+
authorName:
|
|
77
|
+
(account?.display_name as string) || "Donald J. Trump",
|
|
78
|
+
authorImage: (account?.avatar as string) || undefined,
|
|
79
|
+
likes: (status.favourites_count as number) || 0,
|
|
80
|
+
reposts: (status.reblogs_count as number) || 0,
|
|
81
|
+
replies: (status.replies_count as number) || 0,
|
|
82
|
+
url:
|
|
83
|
+
(status.url as string) ||
|
|
84
|
+
`https://truthsocial.com/@realDonaldTrump/${status.id}`,
|
|
85
|
+
timestamp:
|
|
86
|
+
(status.created_at as string) || new Date().toISOString(),
|
|
87
|
+
platform: "truthsocial" as const,
|
|
88
|
+
images: media
|
|
89
|
+
?.filter((m) => m.type === "image")
|
|
90
|
+
.map((m) => (m.preview_url as string) || (m.url as string))
|
|
91
|
+
.filter(Boolean),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function GET() {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
|
|
99
|
+
if (cache.items.length > 0 && now - cache.fetchedAt < CACHE_TTL) {
|
|
100
|
+
return NextResponse.json(cache.items);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!pending) {
|
|
104
|
+
pending = fetchTruthPosts().finally(() => { pending = null; });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const posts = await pending;
|
|
109
|
+
if (posts.length > 0) {
|
|
110
|
+
cache = { items: posts, fetchedAt: Date.now() };
|
|
111
|
+
}
|
|
112
|
+
return NextResponse.json(cache.items);
|
|
113
|
+
} catch {
|
|
114
|
+
return NextResponse.json(cache.items);
|
|
115
|
+
}
|
|
116
|
+
}
|