@blockrun/franklin 3.1.2 → 3.2.1
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/dist/banner.js +125 -9
- package/dist/commands/social.d.ts +24 -0
- package/dist/commands/social.js +258 -0
- package/dist/index.js +20 -0
- package/dist/social/a11y.d.ts +54 -0
- package/dist/social/a11y.js +89 -0
- package/dist/social/ai.d.ts +61 -0
- package/dist/social/ai.js +103 -0
- package/dist/social/browser.d.ts +97 -0
- package/dist/social/browser.js +219 -0
- package/dist/social/config.d.ts +43 -0
- package/dist/social/config.js +83 -0
- package/dist/social/db.d.ts +102 -0
- package/dist/social/db.js +248 -0
- package/dist/social/x.d.ts +46 -0
- package/dist/social/x.js +284 -0
- package/package.json +2 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL-backed dedup + reply log for Franklin's social subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately avoids SQLite (no new native dep). Two files at:
|
|
5
|
+
*
|
|
6
|
+
* ~/.blockrun/social-replies.jsonl — append-only reply log
|
|
7
|
+
* ~/.blockrun/social-prekeys.jsonl — append-only snippet-level dedup
|
|
8
|
+
*
|
|
9
|
+
* Both are read into memory at startup for O(1) lookups. At 30 replies/day
|
|
10
|
+
* this hits 10K rows after a year — still <1MB, still fits in memory.
|
|
11
|
+
*
|
|
12
|
+
* Schema improvements over social-bot's bot/db.py:
|
|
13
|
+
* - `status: 'failed'` does NOT block retry (social-bot blacklists failures
|
|
14
|
+
* permanently, which breaks on transient network errors)
|
|
15
|
+
* - Pre-key dedup happens BEFORE LLM call, saving tokens on duplicates
|
|
16
|
+
* - Per-platform + per-handle scoping so running the bot with two accounts
|
|
17
|
+
* against the same DB doesn't cross-contaminate
|
|
18
|
+
*/
|
|
19
|
+
export type ReplyStatus = 'posted' | 'failed' | 'skipped' | 'drafted';
|
|
20
|
+
export type Platform = 'x' | 'reddit';
|
|
21
|
+
export interface ReplyRecord {
|
|
22
|
+
platform: Platform;
|
|
23
|
+
handle: string;
|
|
24
|
+
post_url: string;
|
|
25
|
+
post_title: string;
|
|
26
|
+
post_snippet: string;
|
|
27
|
+
reply_text: string;
|
|
28
|
+
product?: string;
|
|
29
|
+
status: ReplyStatus;
|
|
30
|
+
error_msg?: string;
|
|
31
|
+
cost_usd?: number;
|
|
32
|
+
created_at: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PreKeyRecord {
|
|
35
|
+
platform: Platform;
|
|
36
|
+
handle: string;
|
|
37
|
+
pre_key: string;
|
|
38
|
+
created_at: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compute a stable pre-key for a candidate post from its snippet fields.
|
|
42
|
+
* Used BEFORE the LLM generates a reply so we can skip duplicates without
|
|
43
|
+
* wasting any tokens.
|
|
44
|
+
*/
|
|
45
|
+
export declare function computePreKey(parts: {
|
|
46
|
+
author?: string;
|
|
47
|
+
snippet: string;
|
|
48
|
+
time?: string;
|
|
49
|
+
}): string;
|
|
50
|
+
/**
|
|
51
|
+
* Has this post been seen before (by pre-key)? If true, skip generation.
|
|
52
|
+
*/
|
|
53
|
+
export declare function hasPreKey(platform: Platform, handle: string, preKey: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Commit a pre-key so we don't re-consider this post. Called after we've
|
|
56
|
+
* decided to act on a post (either drafted, posted, or skipped by AI).
|
|
57
|
+
*/
|
|
58
|
+
export declare function commitPreKey(platform: Platform, handle: string, preKey: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Has this canonical URL been successfully posted to before?
|
|
61
|
+
*
|
|
62
|
+
* Only counts status='posted' — unlike social-bot, we do NOT permanently
|
|
63
|
+
* blacklist 'failed' attempts, so transient errors can be retried.
|
|
64
|
+
*/
|
|
65
|
+
export declare function hasPosted(platform: Platform, handle: string, postUrl: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Count today's successful posts for a handle/platform (used for daily caps).
|
|
68
|
+
*/
|
|
69
|
+
export declare function countPostedToday(platform: Platform, handle: string): number;
|
|
70
|
+
/**
|
|
71
|
+
* Append a reply record. Status can be 'drafted' (dry-run), 'posted',
|
|
72
|
+
* 'failed' (transient, retry OK), or 'skipped' (AI returned SKIP).
|
|
73
|
+
*/
|
|
74
|
+
export declare function logReply(rec: Omit<ReplyRecord, 'created_at' | 'post_url'> & {
|
|
75
|
+
post_url: string;
|
|
76
|
+
}): void;
|
|
77
|
+
/**
|
|
78
|
+
* Stats summary for `franklin social stats`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function getStats(platform?: Platform, handle?: string): {
|
|
81
|
+
total: number;
|
|
82
|
+
posted: number;
|
|
83
|
+
failed: number;
|
|
84
|
+
skipped: number;
|
|
85
|
+
drafted: number;
|
|
86
|
+
today: number;
|
|
87
|
+
totalCost: number;
|
|
88
|
+
byProduct: Record<string, number>;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Canonicalise a URL for stable dedup keys:
|
|
92
|
+
* - lowercase host
|
|
93
|
+
* - strip trailing slash
|
|
94
|
+
* - strip tracking params (?s=, ?t=, utm_*)
|
|
95
|
+
* - x.com and twitter.com are aliases
|
|
96
|
+
*/
|
|
97
|
+
export declare function normaliseUrl(raw: string): string;
|
|
98
|
+
/**
|
|
99
|
+
* Test helper — reset in-memory indexes so the next call re-reads from disk.
|
|
100
|
+
* Not exported from the public API via index.ts.
|
|
101
|
+
*/
|
|
102
|
+
export declare function _resetForTest(): void;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL-backed dedup + reply log for Franklin's social subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately avoids SQLite (no new native dep). Two files at:
|
|
5
|
+
*
|
|
6
|
+
* ~/.blockrun/social-replies.jsonl — append-only reply log
|
|
7
|
+
* ~/.blockrun/social-prekeys.jsonl — append-only snippet-level dedup
|
|
8
|
+
*
|
|
9
|
+
* Both are read into memory at startup for O(1) lookups. At 30 replies/day
|
|
10
|
+
* this hits 10K rows after a year — still <1MB, still fits in memory.
|
|
11
|
+
*
|
|
12
|
+
* Schema improvements over social-bot's bot/db.py:
|
|
13
|
+
* - `status: 'failed'` does NOT block retry (social-bot blacklists failures
|
|
14
|
+
* permanently, which breaks on transient network errors)
|
|
15
|
+
* - Pre-key dedup happens BEFORE LLM call, saving tokens on duplicates
|
|
16
|
+
* - Per-platform + per-handle scoping so running the bot with two accounts
|
|
17
|
+
* against the same DB doesn't cross-contaminate
|
|
18
|
+
*/
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
const STORE_DIR = path.join(os.homedir(), '.blockrun');
|
|
24
|
+
const REPLIES_PATH = path.join(STORE_DIR, 'social-replies.jsonl');
|
|
25
|
+
const PREKEYS_PATH = path.join(STORE_DIR, 'social-prekeys.jsonl');
|
|
26
|
+
// ─── In-memory indexes, loaded lazily on first use ─────────────────────────
|
|
27
|
+
let repliesLoaded = false;
|
|
28
|
+
let replies = [];
|
|
29
|
+
let repliesByUrl = new Map(); // canonical URL → records
|
|
30
|
+
let repliesToday = new Map(); // handle:platform:date → count
|
|
31
|
+
let preKeysLoaded = false;
|
|
32
|
+
let preKeysSet = new Set(); // composite key for O(1) lookup
|
|
33
|
+
function ensureDir() {
|
|
34
|
+
if (!fs.existsSync(STORE_DIR))
|
|
35
|
+
fs.mkdirSync(STORE_DIR, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
function loadReplies() {
|
|
38
|
+
if (repliesLoaded)
|
|
39
|
+
return;
|
|
40
|
+
ensureDir();
|
|
41
|
+
replies = [];
|
|
42
|
+
repliesByUrl.clear();
|
|
43
|
+
repliesToday.clear();
|
|
44
|
+
if (fs.existsSync(REPLIES_PATH)) {
|
|
45
|
+
const text = fs.readFileSync(REPLIES_PATH, 'utf8');
|
|
46
|
+
for (const line of text.split('\n')) {
|
|
47
|
+
if (!line.trim())
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const rec = JSON.parse(line);
|
|
51
|
+
replies.push(rec);
|
|
52
|
+
const list = repliesByUrl.get(rec.post_url) ?? [];
|
|
53
|
+
list.push(rec);
|
|
54
|
+
repliesByUrl.set(rec.post_url, list);
|
|
55
|
+
if (rec.status === 'posted') {
|
|
56
|
+
const dayKey = `${rec.handle}:${rec.platform}:${rec.created_at.slice(0, 10)}`;
|
|
57
|
+
repliesToday.set(dayKey, (repliesToday.get(dayKey) ?? 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Skip malformed lines — append-only file may have a partial last line
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
repliesLoaded = true;
|
|
66
|
+
}
|
|
67
|
+
function loadPreKeys() {
|
|
68
|
+
if (preKeysLoaded)
|
|
69
|
+
return;
|
|
70
|
+
ensureDir();
|
|
71
|
+
preKeysSet.clear();
|
|
72
|
+
if (fs.existsSync(PREKEYS_PATH)) {
|
|
73
|
+
const text = fs.readFileSync(PREKEYS_PATH, 'utf8');
|
|
74
|
+
for (const line of text.split('\n')) {
|
|
75
|
+
if (!line.trim())
|
|
76
|
+
continue;
|
|
77
|
+
try {
|
|
78
|
+
const rec = JSON.parse(line);
|
|
79
|
+
preKeysSet.add(compositePreKey(rec.platform, rec.handle, rec.pre_key));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Skip malformed lines
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
preKeysLoaded = true;
|
|
87
|
+
}
|
|
88
|
+
function compositePreKey(platform, handle, preKey) {
|
|
89
|
+
return `${platform}|${handle}|${preKey}`;
|
|
90
|
+
}
|
|
91
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
92
|
+
/**
|
|
93
|
+
* Compute a stable pre-key for a candidate post from its snippet fields.
|
|
94
|
+
* Used BEFORE the LLM generates a reply so we can skip duplicates without
|
|
95
|
+
* wasting any tokens.
|
|
96
|
+
*/
|
|
97
|
+
export function computePreKey(parts) {
|
|
98
|
+
const normalised = (parts.author ?? '').trim().toLowerCase() + '|' +
|
|
99
|
+
parts.snippet.trim().slice(0, 80).toLowerCase() + '|' +
|
|
100
|
+
(parts.time ?? '').trim();
|
|
101
|
+
return crypto.createHash('sha256').update(normalised).digest('hex').slice(0, 16);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Has this post been seen before (by pre-key)? If true, skip generation.
|
|
105
|
+
*/
|
|
106
|
+
export function hasPreKey(platform, handle, preKey) {
|
|
107
|
+
loadPreKeys();
|
|
108
|
+
return preKeysSet.has(compositePreKey(platform, handle, preKey));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Commit a pre-key so we don't re-consider this post. Called after we've
|
|
112
|
+
* decided to act on a post (either drafted, posted, or skipped by AI).
|
|
113
|
+
*/
|
|
114
|
+
export function commitPreKey(platform, handle, preKey) {
|
|
115
|
+
loadPreKeys();
|
|
116
|
+
const composite = compositePreKey(platform, handle, preKey);
|
|
117
|
+
if (preKeysSet.has(composite))
|
|
118
|
+
return;
|
|
119
|
+
preKeysSet.add(composite);
|
|
120
|
+
const rec = {
|
|
121
|
+
platform,
|
|
122
|
+
handle,
|
|
123
|
+
pre_key: preKey,
|
|
124
|
+
created_at: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
ensureDir();
|
|
127
|
+
fs.appendFileSync(PREKEYS_PATH, JSON.stringify(rec) + '\n');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Has this canonical URL been successfully posted to before?
|
|
131
|
+
*
|
|
132
|
+
* Only counts status='posted' — unlike social-bot, we do NOT permanently
|
|
133
|
+
* blacklist 'failed' attempts, so transient errors can be retried.
|
|
134
|
+
*/
|
|
135
|
+
export function hasPosted(platform, handle, postUrl) {
|
|
136
|
+
loadReplies();
|
|
137
|
+
const recs = repliesByUrl.get(normaliseUrl(postUrl)) ?? [];
|
|
138
|
+
return recs.some((r) => r.platform === platform && r.handle === handle && r.status === 'posted');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Count today's successful posts for a handle/platform (used for daily caps).
|
|
142
|
+
*/
|
|
143
|
+
export function countPostedToday(platform, handle) {
|
|
144
|
+
loadReplies();
|
|
145
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
146
|
+
const key = `${handle}:${platform}:${today}`;
|
|
147
|
+
return repliesToday.get(key) ?? 0;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Append a reply record. Status can be 'drafted' (dry-run), 'posted',
|
|
151
|
+
* 'failed' (transient, retry OK), or 'skipped' (AI returned SKIP).
|
|
152
|
+
*/
|
|
153
|
+
export function logReply(rec) {
|
|
154
|
+
loadReplies();
|
|
155
|
+
const record = {
|
|
156
|
+
...rec,
|
|
157
|
+
post_url: normaliseUrl(rec.post_url),
|
|
158
|
+
created_at: new Date().toISOString(),
|
|
159
|
+
};
|
|
160
|
+
replies.push(record);
|
|
161
|
+
const list = repliesByUrl.get(record.post_url) ?? [];
|
|
162
|
+
list.push(record);
|
|
163
|
+
repliesByUrl.set(record.post_url, list);
|
|
164
|
+
if (record.status === 'posted') {
|
|
165
|
+
const dayKey = `${record.handle}:${record.platform}:${record.created_at.slice(0, 10)}`;
|
|
166
|
+
repliesToday.set(dayKey, (repliesToday.get(dayKey) ?? 0) + 1);
|
|
167
|
+
}
|
|
168
|
+
ensureDir();
|
|
169
|
+
fs.appendFileSync(REPLIES_PATH, JSON.stringify(record) + '\n');
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Stats summary for `franklin social stats`.
|
|
173
|
+
*/
|
|
174
|
+
export function getStats(platform, handle) {
|
|
175
|
+
loadReplies();
|
|
176
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
177
|
+
const filtered = replies.filter((r) => {
|
|
178
|
+
if (platform && r.platform !== platform)
|
|
179
|
+
return false;
|
|
180
|
+
if (handle && r.handle !== handle)
|
|
181
|
+
return false;
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
const byProduct = {};
|
|
185
|
+
let totalCost = 0;
|
|
186
|
+
let todayCount = 0;
|
|
187
|
+
const statusCounts = { posted: 0, failed: 0, skipped: 0, drafted: 0 };
|
|
188
|
+
for (const r of filtered) {
|
|
189
|
+
statusCounts[r.status] =
|
|
190
|
+
(statusCounts[r.status] ?? 0) + 1;
|
|
191
|
+
if (r.product)
|
|
192
|
+
byProduct[r.product] = (byProduct[r.product] ?? 0) + 1;
|
|
193
|
+
if (r.cost_usd)
|
|
194
|
+
totalCost += r.cost_usd;
|
|
195
|
+
if (r.status === 'posted' && r.created_at.startsWith(today))
|
|
196
|
+
todayCount++;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
total: filtered.length,
|
|
200
|
+
...statusCounts,
|
|
201
|
+
today: todayCount,
|
|
202
|
+
totalCost,
|
|
203
|
+
byProduct,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Canonicalise a URL for stable dedup keys:
|
|
208
|
+
* - lowercase host
|
|
209
|
+
* - strip trailing slash
|
|
210
|
+
* - strip tracking params (?s=, ?t=, utm_*)
|
|
211
|
+
* - x.com and twitter.com are aliases
|
|
212
|
+
*/
|
|
213
|
+
export function normaliseUrl(raw) {
|
|
214
|
+
try {
|
|
215
|
+
const u = new URL(raw);
|
|
216
|
+
u.hostname = u.hostname.toLowerCase();
|
|
217
|
+
if (u.hostname === 'twitter.com' || u.hostname === 'mobile.twitter.com') {
|
|
218
|
+
u.hostname = 'x.com';
|
|
219
|
+
}
|
|
220
|
+
// Strip common tracking params
|
|
221
|
+
const toStrip = [];
|
|
222
|
+
u.searchParams.forEach((_v, k) => {
|
|
223
|
+
if (k.startsWith('utm_') || k === 's' || k === 't' || k === 'ref')
|
|
224
|
+
toStrip.push(k);
|
|
225
|
+
});
|
|
226
|
+
for (const k of toStrip)
|
|
227
|
+
u.searchParams.delete(k);
|
|
228
|
+
let s = u.toString();
|
|
229
|
+
if (s.endsWith('/'))
|
|
230
|
+
s = s.slice(0, -1);
|
|
231
|
+
return s;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return raw.trim();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Test helper — reset in-memory indexes so the next call re-reads from disk.
|
|
239
|
+
* Not exported from the public API via index.ts.
|
|
240
|
+
*/
|
|
241
|
+
export function _resetForTest() {
|
|
242
|
+
repliesLoaded = false;
|
|
243
|
+
preKeysLoaded = false;
|
|
244
|
+
replies = [];
|
|
245
|
+
repliesByUrl.clear();
|
|
246
|
+
repliesToday.clear();
|
|
247
|
+
preKeysSet.clear();
|
|
248
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X (Twitter) flow for Franklin's social subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Port of social-bot/bot/x_bot.py with three meaningful changes:
|
|
5
|
+
*
|
|
6
|
+
* 1. Pre-key dedup runs BEFORE the LLM call (social-bot runs it after,
|
|
7
|
+
* wasting Sonnet tokens on every duplicate).
|
|
8
|
+
* 2. 'failed' status does NOT blacklist — only 'posted' does.
|
|
9
|
+
* A transient network error can be retried on the next run.
|
|
10
|
+
* 3. Reply textbox is located via Playwright role selectors, not by
|
|
11
|
+
* counting buttons in a list — less fragile to X DOM changes.
|
|
12
|
+
*
|
|
13
|
+
* Every browser interaction uses argv-based Playwright calls — zero shell
|
|
14
|
+
* injection surface even if the LLM emits `$(rm -rf /)` in reply text.
|
|
15
|
+
*/
|
|
16
|
+
import type { SocialConfig } from './config.js';
|
|
17
|
+
import type { Chain } from '../config.js';
|
|
18
|
+
export interface RunOptions {
|
|
19
|
+
config: SocialConfig;
|
|
20
|
+
model: string;
|
|
21
|
+
apiUrl: string;
|
|
22
|
+
chain: Chain;
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
debug?: boolean;
|
|
25
|
+
onProgress?: (msg: string) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface RunResult {
|
|
28
|
+
considered: number;
|
|
29
|
+
dedupSkipped: number;
|
|
30
|
+
llmSkipped: number;
|
|
31
|
+
drafted: number;
|
|
32
|
+
posted: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
totalCost: number;
|
|
35
|
+
}
|
|
36
|
+
export interface CandidatePost {
|
|
37
|
+
snippetRef: string;
|
|
38
|
+
articleRef: string;
|
|
39
|
+
snippet: string;
|
|
40
|
+
timeText: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Main entry point. Iterates every search query in config.x.search_queries
|
|
44
|
+
* and processes every visible candidate until the daily target is hit.
|
|
45
|
+
*/
|
|
46
|
+
export declare function runX(opts: RunOptions): Promise<RunResult>;
|
package/dist/social/x.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X (Twitter) flow for Franklin's social subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Port of social-bot/bot/x_bot.py with three meaningful changes:
|
|
5
|
+
*
|
|
6
|
+
* 1. Pre-key dedup runs BEFORE the LLM call (social-bot runs it after,
|
|
7
|
+
* wasting Sonnet tokens on every duplicate).
|
|
8
|
+
* 2. 'failed' status does NOT blacklist — only 'posted' does.
|
|
9
|
+
* A transient network error can be retried on the next run.
|
|
10
|
+
* 3. Reply textbox is located via Playwright role selectors, not by
|
|
11
|
+
* counting buttons in a list — less fragile to X DOM changes.
|
|
12
|
+
*
|
|
13
|
+
* Every browser interaction uses argv-based Playwright calls — zero shell
|
|
14
|
+
* injection surface even if the LLM emits `$(rm -rf /)` in reply text.
|
|
15
|
+
*/
|
|
16
|
+
import { SocialBrowser } from './browser.js';
|
|
17
|
+
import { findRefs, findStaticText, extractArticleBlocks, X_TIME_LINK_PATTERN } from './a11y.js';
|
|
18
|
+
import { computePreKey, hasPreKey, commitPreKey, hasPosted, countPostedToday, logReply, } from './db.js';
|
|
19
|
+
import { detectProduct, generateReply } from './ai.js';
|
|
20
|
+
/**
|
|
21
|
+
* Main entry point. Iterates every search query in config.x.search_queries
|
|
22
|
+
* and processes every visible candidate until the daily target is hit.
|
|
23
|
+
*/
|
|
24
|
+
export async function runX(opts) {
|
|
25
|
+
const log = opts.onProgress ?? (() => { });
|
|
26
|
+
const handle = opts.config.handle || 'unknown';
|
|
27
|
+
const result = {
|
|
28
|
+
considered: 0,
|
|
29
|
+
dedupSkipped: 0,
|
|
30
|
+
llmSkipped: 0,
|
|
31
|
+
drafted: 0,
|
|
32
|
+
posted: 0,
|
|
33
|
+
failed: 0,
|
|
34
|
+
totalCost: 0,
|
|
35
|
+
};
|
|
36
|
+
const alreadyToday = countPostedToday('x', handle);
|
|
37
|
+
const remainingBudget = Math.max(0, opts.config.x.daily_target - alreadyToday);
|
|
38
|
+
if (remainingBudget === 0) {
|
|
39
|
+
log(`Daily target of ${opts.config.x.daily_target} already hit today. Nothing to do.`);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
log(`Daily budget: ${remainingBudget} posts remaining (of ${opts.config.x.daily_target})`);
|
|
43
|
+
const browser = new SocialBrowser({ headless: false });
|
|
44
|
+
try {
|
|
45
|
+
await browser.launch();
|
|
46
|
+
log('Browser launched. Checking login state…');
|
|
47
|
+
// Verify we're logged in. If the login_detection string isn't visible on
|
|
48
|
+
// x.com's home page, the user needs to run `franklin social login x`.
|
|
49
|
+
await browser.open('https://x.com/home');
|
|
50
|
+
await browser.waitForTimeout(2500);
|
|
51
|
+
const homeTree = await browser.snapshot();
|
|
52
|
+
const loginMarker = opts.config.x.login_detection || opts.config.handle;
|
|
53
|
+
if (loginMarker && !homeTree.includes(loginMarker)) {
|
|
54
|
+
throw new Error(`Not logged in to x.com (looked for "${loginMarker}" on /home). ` +
|
|
55
|
+
`Run: franklin social login x`);
|
|
56
|
+
}
|
|
57
|
+
log('Login confirmed.');
|
|
58
|
+
let postedThisRun = 0;
|
|
59
|
+
for (const query of opts.config.x.search_queries) {
|
|
60
|
+
if (postedThisRun >= remainingBudget) {
|
|
61
|
+
log(`Hit daily budget (${postedThisRun}) — stopping early.`);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
log(`\nSearching X for: ${query}`);
|
|
65
|
+
const searchUrl = `https://x.com/search?q=${encodeURIComponent(query)}&src=typed_query&f=live`;
|
|
66
|
+
await browser.open(searchUrl);
|
|
67
|
+
await browser.waitForTimeout(3500);
|
|
68
|
+
const searchTree = await browser.snapshot();
|
|
69
|
+
const articles = extractArticleBlocks(searchTree);
|
|
70
|
+
log(` Found ${articles.length} posts in results`);
|
|
71
|
+
for (const article of articles) {
|
|
72
|
+
if (postedThisRun >= remainingBudget)
|
|
73
|
+
break;
|
|
74
|
+
result.considered++;
|
|
75
|
+
// Extract the clickable time-link (our open-tweet handle) and the
|
|
76
|
+
// first visible static text (our snippet).
|
|
77
|
+
const timeRefs = findRefs(article.text, 'link', X_TIME_LINK_PATTERN);
|
|
78
|
+
if (timeRefs.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
const timeRef = timeRefs[0];
|
|
81
|
+
const texts = findStaticText(article.text);
|
|
82
|
+
const snippet = texts.slice(0, 3).join(' ').trim();
|
|
83
|
+
if (!snippet || snippet.length < 20)
|
|
84
|
+
continue;
|
|
85
|
+
const timeLinkMatch = new RegExp(`\\[${timeRef}\\][^\\n]*`).exec(article.text);
|
|
86
|
+
const timeText = timeLinkMatch ? timeLinkMatch[0] : '';
|
|
87
|
+
// ── Pre-key dedup: BEFORE any LLM call ──
|
|
88
|
+
const preKey = computePreKey({ snippet, time: timeText });
|
|
89
|
+
if (hasPreKey('x', handle, preKey)) {
|
|
90
|
+
result.dedupSkipped++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Product routing (zero-cost keyword score)
|
|
94
|
+
const product = detectProduct(snippet, opts.config.products);
|
|
95
|
+
if (!product) {
|
|
96
|
+
commitPreKey('x', handle, preKey); // never retry — no product matches
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
log(`\n → ${snippet.slice(0, 80)}…`);
|
|
100
|
+
log(` product: ${product.name}`);
|
|
101
|
+
// Generate reply (this is where we spend LLM tokens)
|
|
102
|
+
let gen;
|
|
103
|
+
try {
|
|
104
|
+
gen = await generateReply({
|
|
105
|
+
post: { title: snippet.slice(0, 120), snippet, platform: 'x' },
|
|
106
|
+
product,
|
|
107
|
+
config: opts.config,
|
|
108
|
+
model: opts.model,
|
|
109
|
+
apiUrl: opts.apiUrl,
|
|
110
|
+
chain: opts.chain,
|
|
111
|
+
debug: opts.debug,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
log(` ✗ generateReply failed: ${err.message}`);
|
|
116
|
+
commitPreKey('x', handle, preKey);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
result.totalCost += gen.cost;
|
|
120
|
+
if (!gen.reply) {
|
|
121
|
+
log(` AI said SKIP`);
|
|
122
|
+
result.llmSkipped++;
|
|
123
|
+
commitPreKey('x', handle, preKey);
|
|
124
|
+
logReply({
|
|
125
|
+
platform: 'x',
|
|
126
|
+
handle,
|
|
127
|
+
post_url: `preview:${preKey}`,
|
|
128
|
+
post_title: snippet.slice(0, 120),
|
|
129
|
+
post_snippet: snippet,
|
|
130
|
+
reply_text: '',
|
|
131
|
+
product: product.name,
|
|
132
|
+
status: 'skipped',
|
|
133
|
+
cost_usd: gen.cost,
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
result.drafted++;
|
|
138
|
+
log(` draft: ${gen.reply}`);
|
|
139
|
+
// ── Dry-run short-circuit ──
|
|
140
|
+
if (opts.dryRun) {
|
|
141
|
+
commitPreKey('x', handle, preKey);
|
|
142
|
+
logReply({
|
|
143
|
+
platform: 'x',
|
|
144
|
+
handle,
|
|
145
|
+
post_url: `preview:${preKey}`,
|
|
146
|
+
post_title: snippet.slice(0, 120),
|
|
147
|
+
post_snippet: snippet,
|
|
148
|
+
reply_text: gen.reply,
|
|
149
|
+
product: product.name,
|
|
150
|
+
status: 'drafted',
|
|
151
|
+
cost_usd: gen.cost,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// ── Live path: open the tweet, dedup by canonical URL, post ──
|
|
156
|
+
let canonicalUrl = '';
|
|
157
|
+
try {
|
|
158
|
+
await browser.click(timeRef);
|
|
159
|
+
await browser.waitForTimeout(3000);
|
|
160
|
+
canonicalUrl = await browser.getUrl();
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
log(` ✗ failed to open tweet: ${err.message}`);
|
|
164
|
+
logReply({
|
|
165
|
+
platform: 'x',
|
|
166
|
+
handle,
|
|
167
|
+
post_url: `preview:${preKey}`,
|
|
168
|
+
post_title: snippet.slice(0, 120),
|
|
169
|
+
post_snippet: snippet,
|
|
170
|
+
reply_text: gen.reply,
|
|
171
|
+
product: product.name,
|
|
172
|
+
status: 'failed',
|
|
173
|
+
error_msg: `open-tweet: ${err.message}`,
|
|
174
|
+
cost_usd: gen.cost,
|
|
175
|
+
});
|
|
176
|
+
result.failed++;
|
|
177
|
+
commitPreKey('x', handle, preKey);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (hasPosted('x', handle, canonicalUrl)) {
|
|
181
|
+
log(` already posted to ${canonicalUrl} — backing out`);
|
|
182
|
+
commitPreKey('x', handle, preKey);
|
|
183
|
+
await browser.press('Alt+ArrowLeft').catch(() => { });
|
|
184
|
+
await browser.waitForTimeout(1500);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// Post the reply
|
|
188
|
+
try {
|
|
189
|
+
await postReply(browser, gen.reply);
|
|
190
|
+
log(` ✓ posted to ${canonicalUrl}`);
|
|
191
|
+
result.posted++;
|
|
192
|
+
postedThisRun++;
|
|
193
|
+
logReply({
|
|
194
|
+
platform: 'x',
|
|
195
|
+
handle,
|
|
196
|
+
post_url: canonicalUrl,
|
|
197
|
+
post_title: snippet.slice(0, 120),
|
|
198
|
+
post_snippet: snippet,
|
|
199
|
+
reply_text: gen.reply,
|
|
200
|
+
product: product.name,
|
|
201
|
+
status: 'posted',
|
|
202
|
+
cost_usd: gen.cost,
|
|
203
|
+
});
|
|
204
|
+
commitPreKey('x', handle, preKey);
|
|
205
|
+
// Respect the rate-limit / anti-spam delay between successes
|
|
206
|
+
await browser.waitForTimeout(opts.config.x.min_delay_seconds * 1000);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
log(` ✗ post failed: ${err.message}`);
|
|
210
|
+
result.failed++;
|
|
211
|
+
logReply({
|
|
212
|
+
platform: 'x',
|
|
213
|
+
handle,
|
|
214
|
+
post_url: canonicalUrl,
|
|
215
|
+
post_title: snippet.slice(0, 120),
|
|
216
|
+
post_snippet: snippet,
|
|
217
|
+
reply_text: gen.reply,
|
|
218
|
+
product: product.name,
|
|
219
|
+
status: 'failed',
|
|
220
|
+
error_msg: err.message,
|
|
221
|
+
cost_usd: gen.cost,
|
|
222
|
+
});
|
|
223
|
+
// Don't commitPreKey — allow retry on next run
|
|
224
|
+
await browser.press('Escape').catch(() => { });
|
|
225
|
+
await browser.waitForTimeout(2000);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await browser.close();
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Post a reply to the currently-open tweet page.
|
|
237
|
+
* Locates the reply textbox, types the reply (paragraphs joined with
|
|
238
|
+
* Enter+Enter), clicks the reply button, confirms the "Your post was sent"
|
|
239
|
+
* banner.
|
|
240
|
+
*/
|
|
241
|
+
async function postReply(browser, reply) {
|
|
242
|
+
// Snapshot and find the reply textbox
|
|
243
|
+
const tree = await browser.snapshot();
|
|
244
|
+
const boxRefs = findRefs(tree, 'textbox', 'Post (your reply|text).*');
|
|
245
|
+
if (boxRefs.length === 0) {
|
|
246
|
+
// Fallback: any textbox containing "reply" or "post"
|
|
247
|
+
const fallback = findRefs(tree, 'textbox', '(?:[Rr]eply|[Pp]ost).*');
|
|
248
|
+
if (fallback.length === 0)
|
|
249
|
+
throw new Error('reply textbox not found');
|
|
250
|
+
await browser.click(fallback[0]);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
await browser.click(boxRefs[0]);
|
|
254
|
+
}
|
|
255
|
+
await browser.waitForTimeout(700);
|
|
256
|
+
// Type paragraphs separated by double-enter.
|
|
257
|
+
// Strip any `$` so it never triggers a variable interpolation in some
|
|
258
|
+
// downstream tool. (Not required for Playwright argv, but defense in depth.)
|
|
259
|
+
const paragraphs = reply.split(/\n{2,}/).map((p) => p.replace(/\s+$/, ''));
|
|
260
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
261
|
+
if (i > 0) {
|
|
262
|
+
await browser.press('Enter');
|
|
263
|
+
await browser.press('Enter');
|
|
264
|
+
}
|
|
265
|
+
await browser.type(paragraphs[i]);
|
|
266
|
+
}
|
|
267
|
+
await browser.waitForTimeout(700);
|
|
268
|
+
// Click the reply (submit) button. The modal's submit button is labelled
|
|
269
|
+
// "Reply" — we take the FIRST match because the inline compose-below-tweet
|
|
270
|
+
// form and the modal don't coexist in the DOM.
|
|
271
|
+
const snapAfter = await browser.snapshot();
|
|
272
|
+
const replyBtns = findRefs(snapAfter, 'button', 'Reply');
|
|
273
|
+
if (replyBtns.length === 0)
|
|
274
|
+
throw new Error('reply submit button not found');
|
|
275
|
+
// If multiple reply buttons (e.g. a toolbar Reply + submit Reply), the
|
|
276
|
+
// submit is usually the last one with the 'Reply' label.
|
|
277
|
+
await browser.click(replyBtns[replyBtns.length - 1]);
|
|
278
|
+
await browser.waitForTimeout(2500);
|
|
279
|
+
// Confirm
|
|
280
|
+
const confirm = await browser.snapshot();
|
|
281
|
+
if (!/Your post was sent|Reply sent|Your reply was sent/.test(confirm)) {
|
|
282
|
+
throw new Error('post-send confirmation banner not found');
|
|
283
|
+
}
|
|
284
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/franklin",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"ink": "^6.8.0",
|
|
71
71
|
"ink-spinner": "^5.0.0",
|
|
72
72
|
"ink-text-input": "^6.0.0",
|
|
73
|
+
"playwright-core": "^1.49.1",
|
|
73
74
|
"react": "^19.2.4"
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|