pressclaw 0.2.0 → 0.4.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/dist/index.js +2212 -203
- package/index.ts +4237 -0
- package/openclaw.plugin.json +5 -5
- package/package.json +14 -3
- package/templates/default.md +1 -1
- package/LICENSE +0 -21
- package/README.md +0 -394
package/dist/index.js
CHANGED
|
@@ -2,6 +2,157 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
|
|
8
|
+
// ../core/dist/structures.js
|
|
9
|
+
var STRUCTURES = [
|
|
10
|
+
{
|
|
11
|
+
name: "prose",
|
|
12
|
+
description: "Flowing paragraphs, no decoration \u2014 best for reflective/philosophical posts",
|
|
13
|
+
instructions: "Flowing paragraphs. No headers, no lists, no bold. Just clean prose with paragraph breaks. 3-5 paragraphs of substantial length (4-6 sentences each). Every sentence should carry weight. Prioritize precision and nuance over accessibility. Total: 300-500 words."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "list",
|
|
17
|
+
description: "Numbered points with sub-details \u2014 best for actionable advice",
|
|
18
|
+
instructions: "Open with 1-2 sentences framing the problem or context. Then present 3-7 numbered points, each with a bold lead-in phrase followed by 1-2 sentences of explanation. Points should be scannable \u2014 a reader skimming just the bold parts should get the gist. Close with a single-paragraph synthesis or call-to-action. Total: 250-450 words."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "story",
|
|
22
|
+
description: "Narrative arc \u2014 best for experience-based posts",
|
|
23
|
+
instructions: "Structure as a narrative: (1) Hook \u2014 a specific moment, scene, or problem that pulls the reader in. (2) Context \u2014 brief setup of what you were doing and why. (3) Tension \u2014 what went wrong, what was surprising, what you struggled with. (4) Resolution \u2014 what you figured out, built, or changed. (5) Insight \u2014 the universal lesson extracted from the specific experience. Use 'I' naturally. Show, don't just tell. Total: 300-600 words."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "chunky",
|
|
27
|
+
description: "Short paragraphs, rhythm-based \u2014 best for mobile/scrollable reading",
|
|
28
|
+
instructions: "Write in short, punchy paragraphs of 1-3 sentences each. No headers. Create rhythm through paragraph length variation: short, medium, short, long, short. Use line breaks generously. Each paragraph should carry one thought. The pacing should feel like a conversation \u2014 quick, direct, breathing room between ideas. Total: 200-400 words."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "minimal",
|
|
32
|
+
description: "Stripped down, no padding \u2014 best for sharp takes",
|
|
33
|
+
instructions: "Under 150 words. One core idea, zero padding. The Hemingway version. Every word earns its place. No headers, no lists. 2-3 tight paragraphs max. End on the strongest line."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "structured",
|
|
37
|
+
description: "Headers + sections \u2014 best for technical explanations",
|
|
38
|
+
instructions: "Format with a clear hierarchy: a strong H1 title, 2-4 H2 sections that break down the idea logically. Each section should have 1-3 focused paragraphs. Use H3 sparingly for sub-points. Include a brief intro paragraph before the first section. End with a 'Takeaway' or 'Bottom Line' section. Total: 300-500 words."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "thread",
|
|
42
|
+
description: "Hook-first, numbered segments \u2014 best for Twitter/X distribution",
|
|
43
|
+
instructions: "Write as a thread of 5-10 numbered segments. Each segment should be ~280 characters max (1-2 sentences). Segment 1 is the hook \u2014 provocative, surprising, or contrarian. Segments 2-N develop the idea one beat at a time. Final segment is a crisp takeaway or call to discussion. Use emoji sparingly (0-2 per segment). Each segment should stand alone but build on the previous. End with a single-line summary of the whole thread."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "tldr",
|
|
47
|
+
description: "Conclusion-first, then expand \u2014 best for busy readers",
|
|
48
|
+
instructions: "Start with the conclusion or key insight in bold \u2014 the 'TL;DR' in 1-2 sentences. Then expand with 3-5 paragraphs that provide evidence, context, and nuance. The reader who stops after the first paragraph should still get the core value. Those who continue get the depth. End with a forward-looking question or implication. Total: 250-450 words."
|
|
49
|
+
}
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// ../core/dist/utils.js
|
|
53
|
+
function escapeHtml(input) {
|
|
54
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
55
|
+
}
|
|
56
|
+
function slugify(input) {
|
|
57
|
+
return input.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
58
|
+
}
|
|
59
|
+
function excerptFrom(body, maxLen = 180) {
|
|
60
|
+
return body.replace(/[#*_`>\[\]]/g, "").split("\n").filter((l) => l.trim()).slice(0, 2).join(" ").slice(0, maxLen);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ../core/dist/markdown.js
|
|
64
|
+
function renderInline(text) {
|
|
65
|
+
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
66
|
+
text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
|
67
|
+
text = text.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, "<em>$1</em>");
|
|
68
|
+
text = text.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, "<em>$1</em>");
|
|
69
|
+
text = text.replace(/`([^`]+?)`/g, "<code>$1</code>");
|
|
70
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
function renderMarkdown(md) {
|
|
74
|
+
const rawLines = md.split("\n");
|
|
75
|
+
const blocks = [];
|
|
76
|
+
let para = [];
|
|
77
|
+
let inCodeBlock = false;
|
|
78
|
+
let codeLines = [];
|
|
79
|
+
let codeLang = "";
|
|
80
|
+
const flushPara = () => {
|
|
81
|
+
if (para.length === 0)
|
|
82
|
+
return;
|
|
83
|
+
blocks.push(`<p>${para.map(renderInline).join(" ")}</p>`);
|
|
84
|
+
para = [];
|
|
85
|
+
};
|
|
86
|
+
for (const rawLine of rawLines) {
|
|
87
|
+
if (/^```/.test(rawLine)) {
|
|
88
|
+
if (!inCodeBlock) {
|
|
89
|
+
flushPara();
|
|
90
|
+
inCodeBlock = true;
|
|
91
|
+
codeLang = rawLine.replace(/^```/, "").trim();
|
|
92
|
+
codeLines = [];
|
|
93
|
+
} else {
|
|
94
|
+
blocks.push(`<pre><code${codeLang ? ` class="language-${escapeHtml(codeLang)}"` : ""}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
95
|
+
inCodeBlock = false;
|
|
96
|
+
codeLines = [];
|
|
97
|
+
codeLang = "";
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inCodeBlock) {
|
|
102
|
+
codeLines.push(rawLine);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const line = rawLine;
|
|
106
|
+
if (/^---+\s*$/.test(line)) {
|
|
107
|
+
flushPara();
|
|
108
|
+
blocks.push("<hr/>");
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (/^###\s+/.test(line)) {
|
|
112
|
+
flushPara();
|
|
113
|
+
blocks.push(`<h3>${renderInline(escapeHtml(line.replace(/^###\s+/, "")))}</h3>`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (/^##\s+/.test(line)) {
|
|
117
|
+
flushPara();
|
|
118
|
+
blocks.push(`<h2>${renderInline(escapeHtml(line.replace(/^##\s+/, "")))}</h2>`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (/^#\s+/.test(line)) {
|
|
122
|
+
flushPara();
|
|
123
|
+
blocks.push(`<h1>${renderInline(escapeHtml(line.replace(/^#\s+/, "")))}</h1>`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (/^[-*]\s+/.test(line)) {
|
|
127
|
+
flushPara();
|
|
128
|
+
blocks.push(`<li>${renderInline(escapeHtml(line.replace(/^[-*]\s+/, "")))}</li>`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (/^>\s*/.test(line)) {
|
|
132
|
+
flushPara();
|
|
133
|
+
blocks.push(`<blockquote><p>${renderInline(escapeHtml(line.replace(/^>\s*/, "")))}</p></blockquote>`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (line.trim() === "") {
|
|
137
|
+
flushPara();
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
para.push(escapeHtml(line));
|
|
141
|
+
}
|
|
142
|
+
flushPara();
|
|
143
|
+
let html = blocks.join("\n");
|
|
144
|
+
html = html.replace(/(<li>.*?<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
|
|
145
|
+
return html;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ../core/dist/html.js
|
|
149
|
+
function generateOgImageUrl(title, username) {
|
|
150
|
+
const params = new URLSearchParams({
|
|
151
|
+
title,
|
|
152
|
+
author: username
|
|
153
|
+
});
|
|
154
|
+
return `https://pressclaw.com/__og?${params.toString()}`;
|
|
155
|
+
}
|
|
5
156
|
var STYLES = `
|
|
6
157
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; max-width: 680px; margin: 48px auto; padding: 0 24px; line-height: 1.7; color: #1a1a1a; }
|
|
7
158
|
h1 { font-size: 1.8em; margin-bottom: 0.3em; line-height: 1.2; }
|
|
@@ -21,51 +172,159 @@ var STYLES = `
|
|
|
21
172
|
.meta { color: #71717a; font-size: 0.9em; margin-bottom: 2em; }
|
|
22
173
|
footer { margin-top: 3em; padding-top: 1.5em; border-top: 1px solid #e4e4e7; color: #71717a; font-size: 0.85em; }
|
|
23
174
|
footer a { color: #71717a; }
|
|
175
|
+
.cta-bar { margin-top: 2em; padding: 20px 24px; background: #f8f9fa; border-radius: 8px; text-align: center; font-size: 0.9em; }
|
|
176
|
+
.cta-bar a { color: #0b5fff; font-weight: 600; }
|
|
24
177
|
`;
|
|
25
|
-
|
|
178
|
+
function renderPostPage(title, bodyHtml, meta) {
|
|
179
|
+
const backLink = meta.username ? `<a href="/">\u2190 ${escapeHtml(meta.siteTitle)}</a>` : `<a href="../">\u2190 ${escapeHtml(meta.siteTitle)}</a>`;
|
|
180
|
+
const ogImageUrl = meta.username ? generateOgImageUrl(title, meta.username) : void 0;
|
|
181
|
+
return `<!doctype html>
|
|
26
182
|
<html lang="en">
|
|
27
183
|
<head>
|
|
28
184
|
<meta charset="utf-8" />
|
|
29
|
-
<title>${title} \u2014 ${meta.siteTitle}</title>
|
|
185
|
+
<title>${escapeHtml(title)} \u2014 ${escapeHtml(meta.siteTitle)}</title>
|
|
30
186
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
187
|
+
<meta property="og:title" content="${escapeHtml(title)}" />
|
|
188
|
+
<meta property="og:site_name" content="${escapeHtml(meta.siteTitle)}" />
|
|
189
|
+
<meta property="og:type" content="article" />${ogImageUrl ? `
|
|
190
|
+
<meta property="og:image" content="${escapeHtml(ogImageUrl)}" />
|
|
191
|
+
<meta property="og:image:width" content="1200" />
|
|
192
|
+
<meta property="og:image:height" content="630" />
|
|
193
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
194
|
+
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
|
195
|
+
<meta name="twitter:image" content="${escapeHtml(ogImageUrl)}" />` : `
|
|
196
|
+
<meta name="twitter:card" content="summary" />`}
|
|
31
197
|
<style>${STYLES}</style>
|
|
32
198
|
</head>
|
|
33
199
|
<body>
|
|
34
|
-
<h1>${title}</h1>
|
|
35
|
-
${meta.date ? `<div class="meta">${meta.date}${meta.authorName ? ` \xB7 ${meta.authorName}` : ""}</div>` : ""}
|
|
36
|
-
<article>${
|
|
37
|
-
<footer
|
|
200
|
+
<h1>${escapeHtml(title)}</h1>
|
|
201
|
+
${meta.date ? `<div class="meta">${escapeHtml(meta.date)}${meta.authorName ? ` \xB7 ${escapeHtml(meta.authorName)}` : ""}</div>` : ""}
|
|
202
|
+
<article>${bodyHtml}</article>
|
|
203
|
+
<footer>
|
|
204
|
+
${backLink}
|
|
205
|
+
<span style="float:right">Published with <a href="https://pressclaw.com">PressClaw</a></span>
|
|
206
|
+
</footer>
|
|
207
|
+
<div class="cta-bar">
|
|
208
|
+
<p>\u270D\uFE0F Want your own blog? <a href="https://pressclaw.com">Message a bot, get a blog</a> \u2014 free, 60 seconds.</p>
|
|
209
|
+
</div>
|
|
38
210
|
</body>
|
|
39
211
|
</html>`;
|
|
40
|
-
|
|
212
|
+
}
|
|
213
|
+
function renderIndexPage(items, meta) {
|
|
214
|
+
const rssHref = meta.baseUrl ? `${meta.baseUrl}/rss.xml` : "/rss.xml";
|
|
215
|
+
return `<!doctype html>
|
|
41
216
|
<html lang="en">
|
|
42
217
|
<head>
|
|
43
218
|
<meta charset="utf-8" />
|
|
44
|
-
<title>${meta.siteTitle}</title>
|
|
219
|
+
<title>${escapeHtml(meta.siteTitle)}</title>
|
|
45
220
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
46
|
-
<
|
|
221
|
+
<link rel="alternate" type="application/rss+xml" title="${escapeHtml(meta.siteTitle)}" href="${rssHref}" />
|
|
222
|
+
<style>${STYLES} .post-list { list-style: none; padding: 0; } .post-list li { margin: 1.5em 0; } .post-list .date { color: #71717a; font-size: 0.85em; } .post-list .excerpt { color: #52525b; font-size: 0.95em; margin-top: 0.3em; } .powered-by { margin-top: 3em; padding: 24px; background: #f8f9fa; border-radius: 8px; text-align: center; } .powered-by p { margin: 0.3em 0; color: #52525b; font-size: 0.9em; } .start-link { color: #0b5fff !important; font-weight: 600; font-size: 1em; }</style>
|
|
47
223
|
</head>
|
|
48
224
|
<body>
|
|
49
|
-
<h1>${meta.siteTitle}</h1>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
225
|
+
<h1>${escapeHtml(meta.siteTitle)}</h1>
|
|
226
|
+
${meta.bio ? `<p class="meta">${escapeHtml(meta.bio)}</p>` : ""}
|
|
227
|
+
${meta.postCount ? `<p class="meta">${meta.postCount} post${meta.postCount === 1 ? "" : "s"} published</p>` : ""}
|
|
228
|
+
${items.length === 0 ? "<p>No posts yet.</p>" : `<ul class="post-list">
|
|
229
|
+
${items.map((i) => `<li><a href="/${i.slug}">${escapeHtml(i.title)}</a><div class="date">${escapeHtml(i.date)}</div><div class="excerpt">${escapeHtml(i.excerpt)}</div></li>`).join("\n ")}
|
|
230
|
+
</ul>`}
|
|
231
|
+
${items.length > 0 ? `<div class="powered-by">
|
|
232
|
+
<p>\u270D\uFE0F This blog was made by messaging a bot on Telegram. No setup needed.</p>
|
|
233
|
+
<p><a href="https://pressclaw.com" class="start-link">Start your own blog \u2192</a></p>
|
|
234
|
+
</div>` : ""}
|
|
235
|
+
<footer>
|
|
236
|
+
<a href="${rssHref}">RSS</a>
|
|
237
|
+
<span style="float:right"><a href="https://pressclaw.com">PressClaw</a></span>
|
|
238
|
+
</footer>
|
|
53
239
|
</body>
|
|
54
240
|
</html>`;
|
|
55
|
-
|
|
56
|
-
|
|
241
|
+
}
|
|
242
|
+
function renderRss(items, meta) {
|
|
243
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
244
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
57
245
|
<channel>
|
|
58
|
-
<title>${meta.siteTitle}</title>
|
|
59
|
-
<link>${meta.baseUrl
|
|
60
|
-
<description>${meta.siteTitle}</description>
|
|
61
|
-
|
|
246
|
+
<title>${escapeHtml(meta.siteTitle)}</title>
|
|
247
|
+
<link>${meta.baseUrl}</link>
|
|
248
|
+
<description>${escapeHtml(meta.siteTitle)}</description>
|
|
249
|
+
<atom:link href="${meta.baseUrl}/rss.xml" rel="self" type="application/rss+xml" />
|
|
250
|
+
${items.map((i) => `<item>
|
|
251
|
+
<title>${escapeHtml(i.title)}</title>
|
|
252
|
+
<link>${meta.baseUrl}/${i.slug}</link>
|
|
253
|
+
<guid>${meta.baseUrl}/${i.slug}</guid>
|
|
254
|
+
<pubDate>${new Date(i.date).toUTCString()}</pubDate>
|
|
255
|
+
<description><![CDATA[${i.excerpt}]]></description>
|
|
256
|
+
</item>`).join("\n ")}
|
|
62
257
|
</channel>
|
|
63
258
|
</rss>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ../core/dist/reddit.js
|
|
262
|
+
var SUBREDDIT_GUIDELINES = {
|
|
263
|
+
// ── Business & Startups ───────────────────────────────────────
|
|
264
|
+
"r/SaaS": "Metrics welcome. Revenue numbers get engagement. Be specific about what worked or failed. Technical details appreciated. 'I grew from $0 to $X MRR' posts do great. Audience: SaaS founders and operators.",
|
|
265
|
+
"r/startups": "Process-focused. 'Here's how' posts do well. Longer posts are fine if substantive. Show your thinking, not just the outcome. Include lessons, not just wins. Audience: early-stage founders.",
|
|
266
|
+
"r/entrepreneur": "Story-driven. Emotional beats land well. 'I almost quit' narratives. Practical takeaways at the end. Audience: broad, many aspiring entrepreneurs.",
|
|
267
|
+
"r/indiehackers": "Numbers + transparency. MRR, churn, conversion rates. Be raw and honest. Share what didn't work too. The more specific the numbers, the better. Audience: bootstrapped solo founders.",
|
|
268
|
+
"r/smallbusiness": "Practical advice. No jargon. Keep it simple and actionable. Audience: small business owners, many non-technical. They want 'what to do Monday morning.'",
|
|
269
|
+
"r/EntrepreneurRideAlong": "Step-by-step build logs. Show the process, not just the result. Screenshots and numbers appreciated. Very supportive community. Audience: early founders building in public.",
|
|
270
|
+
"r/microsaas": "Small, focused products. Solo builder stories. Low overhead, high margin. Tech stack choices matter here. Revenue per-user metrics valued. Audience: solo SaaS builders.",
|
|
271
|
+
"r/growmybusiness": "Marketing and growth tactics. What channels worked, what didn't. Budget-friendly strategies preferred. Audience: small business owners seeking growth advice.",
|
|
272
|
+
"r/ecommerce": "Platform-specific tips (Shopify, WooCommerce). Conversion rate discussions. Logistics and fulfillment stories welcome. Audience: online store owners.",
|
|
273
|
+
"r/Affiliatemarketing": "Traffic and conversion data. Niche selection stories. SEO strategies. Be honest about timelines \u2014 overnight success claims get called out. Audience: affiliate marketers.",
|
|
274
|
+
// ── Technology & Engineering ───────────────────────────────────
|
|
275
|
+
"r/webdev": "Code-literate audience. Technical depth expected. Framework opinions welcome but back them up. Performance benchmarks and real-world examples valued. No 'my first website' energy \u2014 be substantive. Audience: web developers.",
|
|
276
|
+
"r/programming": "Highly technical. Opinionated. Prefers deep analysis over hot takes. Language-agnostic discussion welcome. Audience: senior developers, CS-oriented.",
|
|
277
|
+
"r/javascript": "Framework debates are constant. Show benchmarks, not opinions. Practical tutorials with real use cases. TypeScript content welcome. Audience: JS/TS developers.",
|
|
278
|
+
"r/typescript": "Type system deep-dives appreciated. Advanced patterns and utility types. Real-world typing challenges and solutions. Audience: TypeScript developers.",
|
|
279
|
+
"r/node": "Backend-focused. Performance, scaling, architecture discussions. Comparison posts (Express vs Fastify vs Hono) do well with benchmarks. Audience: Node.js developers.",
|
|
280
|
+
"r/reactjs": "Component patterns, state management, and performance optimization. Server components and Next.js topics trending. Show code, not just concepts. Audience: React developers.",
|
|
281
|
+
"r/devops": "Infrastructure as code, CI/CD pipelines, monitoring. War stories about outages and postmortems are popular. Audience: DevOps engineers and SREs.",
|
|
282
|
+
"r/selfhosted": "Privacy-conscious. Open source preferred. Docker/compose setups. Comparison of alternatives to SaaS products. 'I replaced X with Y' posts do great. Audience: self-hosters and homelab enthusiasts.",
|
|
283
|
+
"r/sysadmin": "Enterprise-focused. Security and reliability matter most. Vendor frustration stories resonate. Practical advice over theory. Audience: system administrators.",
|
|
284
|
+
"r/MachineLearning": "Paper discussions, research results, benchmarks. High technical bar. Show methodology, not just results. Audience: ML researchers and practitioners.",
|
|
285
|
+
"r/artificial": "Broader AI discussion. Ethics, implications, product announcements. Less technical than r/MachineLearning. Audience: AI-interested generalists.",
|
|
286
|
+
"r/LocalLLaMA": "Self-hosted AI models. Quantization, fine-tuning, benchmarks. Hardware recommendations. Privacy-focused. Audience: local AI enthusiasts.",
|
|
287
|
+
"r/ChatGPT": "Prompt engineering, use cases, limitations. Practical 'I used AI to do X' stories. Audience: AI users, broad technical range.",
|
|
288
|
+
// ── Design & Product ──────────────────────────────────────────
|
|
289
|
+
"r/design": "Visual quality matters \u2014 even in text posts. Design thinking and process. Portfolio/case study format. Audience: designers across disciplines.",
|
|
290
|
+
"r/userexperience": "Research-backed insights. Usability testing results. A/B test results with methodology. Audience: UX designers and researchers.",
|
|
291
|
+
"r/web_design": "Visual trends, CSS techniques, accessibility. Before/after comparisons perform well. Audience: web designers.",
|
|
292
|
+
"r/ProductManagement": "Frameworks and methodologies. Stakeholder management stories. Prioritization approaches. Audience: product managers.",
|
|
293
|
+
// ── Marketing & Growth ────────────────────────────────────────
|
|
294
|
+
"r/marketing": "Strategy discussions. Channel-specific tactics. Budget allocation stories. Data-backed insights preferred over theory. Audience: marketing professionals.",
|
|
295
|
+
"r/SEO": "Algorithm update analysis. Case studies with traffic data. Technical SEO deep-dives. Be specific \u2014 vague 'content is king' posts get destroyed. Audience: SEO professionals.",
|
|
296
|
+
"r/content_marketing": "Content strategy, distribution, repurposing. Show results: traffic, leads, conversions from content. Audience: content marketers.",
|
|
297
|
+
"r/socialmedia": "Platform-specific strategies. Algorithm changes. Engagement tactics. Organic vs paid discussions. Audience: social media managers.",
|
|
298
|
+
"r/digital_marketing": "Paid acquisition, funnel optimization, attribution. Tool comparisons with real data. Audience: digital marketers.",
|
|
299
|
+
"r/copywriting": "Craft-focused. Show before/after copy. Headline formulas with results. Books and learning resources valued. Audience: copywriters.",
|
|
300
|
+
"r/emailmarketing": "Open rates, click rates, deliverability. Segmentation strategies. Subject line testing results. Audience: email marketers.",
|
|
301
|
+
// ── Finance & Investing ───────────────────────────────────────
|
|
302
|
+
"r/personalfinance": "Conservative, evidence-based advice. No get-rich-quick. Budget breakdowns, savings strategies, tax tips. Audience: financially-conscious adults.",
|
|
303
|
+
"r/financialindependence": "FIRE movement. Savings rates, withdrawal strategies. Long time horizons. Spreadsheets and projections valued. Audience: FIRE enthusiasts.",
|
|
304
|
+
// ── Career & Professional ─────────────────────────────────────
|
|
305
|
+
"r/cscareerquestions": "Interview prep, salary negotiation, career progression. Specific company/level info valued. TC (total comp) discussions common. Audience: software engineers.",
|
|
306
|
+
"r/careerguidance": "Broader career advice. Industry transitions. Resume tips. Empathetic tone. Audience: professionals at career crossroads.",
|
|
307
|
+
"r/freelance": "Rates, client management, contracts. War stories and lessons from bad clients resonate. Audience: freelancers.",
|
|
308
|
+
"r/digitalnomad": "Location-specific tips. Cost of living breakdowns. Visa and tax strategies. Audience: remote workers and digital nomads.",
|
|
309
|
+
"r/remotework": "Productivity, communication, tools. Company culture and management perspectives. Audience: remote workers.",
|
|
310
|
+
// ── Writing & Content Creation ────────────────────────────────
|
|
311
|
+
"r/writing": "Craft discussions. Process over product. Vulnerability about struggles resonates. No self-promo. Audience: writers.",
|
|
312
|
+
"r/blogging": "Traffic growth, monetization, SEO for blogs. Income reports do well. Platform comparisons (WordPress vs Ghost etc). Audience: bloggers.",
|
|
313
|
+
"r/ContentCreation": "Multi-platform strategy. Creator economy insights. Monetization approaches. Audience: content creators.",
|
|
314
|
+
// ── Lifestyle & Productivity ──────────────────────────────────
|
|
315
|
+
"r/productivity": "Systems and tools. Time management approaches. Specific workflows > vague advice. Audience: productivity enthusiasts.",
|
|
316
|
+
"r/getdisciplined": "Habit formation, accountability, mindset. Personal transformation stories. Be real, not preachy. Audience: self-improvement seekers.",
|
|
317
|
+
"r/nocode": "Tool-specific tutorials. What you built without code. Comparison of platforms. Audience: non-technical builders."
|
|
318
|
+
};
|
|
319
|
+
var DEFAULT_SUBREDDIT_GUIDELINE = "Write for a general audience. Be authentic and specific. Share real experience. Observe the subreddit's rules and culture before posting.";
|
|
320
|
+
var KNOWN_SUBREDDITS = Object.keys(SUBREDDIT_GUIDELINES);
|
|
321
|
+
|
|
322
|
+
// index.ts
|
|
64
323
|
function resolveWorkspace(api) {
|
|
65
324
|
return api.config?.agents?.defaults?.workspace || process.env.HOME + "/.openclaw/workspace";
|
|
66
325
|
}
|
|
67
326
|
function resolveConfig(api) {
|
|
68
|
-
const cfg = api.config.plugins?.entries?.["
|
|
327
|
+
const cfg = api.config.plugins?.entries?.["pressclaw"]?.config ?? {};
|
|
69
328
|
const workspace = resolveWorkspace(api);
|
|
70
329
|
return {
|
|
71
330
|
notesDir: cfg.notesDir || path.join(workspace, "notes"),
|
|
@@ -98,9 +357,6 @@ function parseFrontMatter(content) {
|
|
|
98
357
|
}
|
|
99
358
|
return { meta, body: match[2].trim() };
|
|
100
359
|
}
|
|
101
|
-
function slugify(input) {
|
|
102
|
-
return input.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
103
|
-
}
|
|
104
360
|
function listNotes(notesDir) {
|
|
105
361
|
if (!fs.existsSync(notesDir)) return [];
|
|
106
362
|
return fs.readdirSync(notesDir).filter((f) => f.endsWith(".md"));
|
|
@@ -283,12 +539,13 @@ function updateAggregateProfile(notesDir) {
|
|
|
283
539
|
}
|
|
284
540
|
function loadStructureTemplates(pluginDir) {
|
|
285
541
|
const p = path.join(pluginDir, "templates", "structures.json");
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
542
|
+
if (fs.existsSync(p)) {
|
|
543
|
+
try {
|
|
544
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
545
|
+
} catch {
|
|
546
|
+
}
|
|
291
547
|
}
|
|
548
|
+
return STRUCTURES;
|
|
292
549
|
}
|
|
293
550
|
function getStructureTemplate(pluginDir, name) {
|
|
294
551
|
return loadStructureTemplates(pluginDir).find((t) => t.name === name);
|
|
@@ -332,11 +589,151 @@ function loadPersonas(notesDir) {
|
|
|
332
589
|
return null;
|
|
333
590
|
}
|
|
334
591
|
}
|
|
335
|
-
function
|
|
336
|
-
return path.join(notesDir, "
|
|
592
|
+
function feedbackDir(notesDir) {
|
|
593
|
+
return path.join(notesDir, ".feedback");
|
|
337
594
|
}
|
|
338
|
-
function
|
|
339
|
-
|
|
595
|
+
function feedbackPath(notesDir, slug) {
|
|
596
|
+
return path.join(feedbackDir(notesDir), `${slug}.json`);
|
|
597
|
+
}
|
|
598
|
+
function loadFeedback(notesDir, slug) {
|
|
599
|
+
const p = feedbackPath(notesDir, slug);
|
|
600
|
+
if (!fs.existsSync(p)) return null;
|
|
601
|
+
try {
|
|
602
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
603
|
+
} catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function loadAllFeedback(notesDir) {
|
|
608
|
+
const dir = feedbackDir(notesDir);
|
|
609
|
+
if (!fs.existsSync(dir)) return [];
|
|
610
|
+
const results = [];
|
|
611
|
+
for (const f of fs.readdirSync(dir).filter((f2) => f2.endsWith(".json"))) {
|
|
612
|
+
try {
|
|
613
|
+
results.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")));
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return results;
|
|
618
|
+
}
|
|
619
|
+
function recalcAggregate(entries) {
|
|
620
|
+
const n = entries.length;
|
|
621
|
+
if (n === 0) return { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 };
|
|
622
|
+
const totalViews = entries.reduce((s, e) => s + (e.metrics.views || 0), 0);
|
|
623
|
+
const totalLikes = entries.reduce((s, e) => s + (e.metrics.likes || 0), 0);
|
|
624
|
+
const totalShares = entries.reduce((s, e) => s + (e.metrics.shares || 0), 0);
|
|
625
|
+
const totalComments = entries.reduce((s, e) => s + (e.metrics.comments || 0), 0);
|
|
626
|
+
const avgScore = Math.round(entries.reduce((s, e) => s + e.score, 0) / n * 10) / 10;
|
|
627
|
+
return { avgScore, totalViews, totalLikes, totalShares, totalComments, entries: n };
|
|
628
|
+
}
|
|
629
|
+
function saveFeedback(notesDir, data) {
|
|
630
|
+
const dir = feedbackDir(notesDir);
|
|
631
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
632
|
+
fs.writeFileSync(feedbackPath(notesDir, data.slug), JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
633
|
+
}
|
|
634
|
+
function twitterOAuthHeader(method, url, consumerKey, consumerSecret, token, tokenSecret) {
|
|
635
|
+
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
636
|
+
const params = {
|
|
637
|
+
oauth_consumer_key: consumerKey,
|
|
638
|
+
oauth_nonce: crypto.randomUUID().replace(/-/g, ""),
|
|
639
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
640
|
+
oauth_timestamp: String(Math.floor(Date.now() / 1e3)),
|
|
641
|
+
oauth_token: token,
|
|
642
|
+
oauth_version: "1.0"
|
|
643
|
+
};
|
|
644
|
+
const sorted = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join("&");
|
|
645
|
+
const baseString = `${method}&${enc(url)}&${enc(sorted)}`;
|
|
646
|
+
const signingKey = `${enc(consumerSecret)}&${enc(tokenSecret)}`;
|
|
647
|
+
const signature = crypto.createHmac("sha1", signingKey).update(baseString).digest("base64");
|
|
648
|
+
params.oauth_signature = signature;
|
|
649
|
+
return "OAuth " + Object.keys(params).sort().map((k) => `${enc(k)}="${enc(params[k])}"`).join(", ");
|
|
650
|
+
}
|
|
651
|
+
function postTweet(text) {
|
|
652
|
+
const apiKey = process.env.TWITTER_API_KEY;
|
|
653
|
+
const apiSecret = process.env.TWITTER_API_SECRET;
|
|
654
|
+
const accessToken = process.env.TWITTER_ACCESS_TOKEN;
|
|
655
|
+
const accessSecret = process.env.TWITTER_ACCESS_SECRET;
|
|
656
|
+
if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
|
|
657
|
+
return Promise.reject(new Error("Twitter credentials not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET environment variables."));
|
|
658
|
+
}
|
|
659
|
+
const url = "https://api.twitter.com/2/tweets";
|
|
660
|
+
const auth = twitterOAuthHeader("POST", url, apiKey, apiSecret, accessToken, accessSecret);
|
|
661
|
+
const body = JSON.stringify({ text });
|
|
662
|
+
return new Promise((resolve, reject) => {
|
|
663
|
+
const req = https.request(url, { method: "POST", headers: { Authorization: auth, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } }, (res) => {
|
|
664
|
+
let data = "";
|
|
665
|
+
res.on("data", (chunk) => data += chunk);
|
|
666
|
+
res.on("end", () => {
|
|
667
|
+
try {
|
|
668
|
+
const parsed = JSON.parse(data);
|
|
669
|
+
if (res.statusCode === 201 && parsed.data) {
|
|
670
|
+
resolve({ id: parsed.data.id, text: parsed.data.text });
|
|
671
|
+
} else {
|
|
672
|
+
reject(new Error(`Twitter API error (${res.statusCode}): ${data}`));
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
reject(new Error(`Twitter API parse error: ${data}`));
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
req.on("error", reject);
|
|
680
|
+
req.write(body);
|
|
681
|
+
req.end();
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
function fetchTweetMetrics(tweetIds) {
|
|
685
|
+
const apiKey = process.env.TWITTER_API_KEY;
|
|
686
|
+
const apiSecret = process.env.TWITTER_API_SECRET;
|
|
687
|
+
const accessToken = process.env.TWITTER_ACCESS_TOKEN;
|
|
688
|
+
const accessSecret = process.env.TWITTER_ACCESS_SECRET;
|
|
689
|
+
if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
|
|
690
|
+
return Promise.reject(new Error("Twitter credentials not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET environment variables."));
|
|
691
|
+
}
|
|
692
|
+
const batches = [];
|
|
693
|
+
for (let i = 0; i < tweetIds.length; i += 100) {
|
|
694
|
+
batches.push(tweetIds.slice(i, i + 100));
|
|
695
|
+
}
|
|
696
|
+
return Promise.all(batches.map((batch) => {
|
|
697
|
+
const url = `https://api.twitter.com/2/tweets?ids=${batch.join(",")}&tweet.fields=public_metrics`;
|
|
698
|
+
const auth = twitterOAuthHeader("GET", url.split("?")[0], apiKey, apiSecret, accessToken, accessSecret);
|
|
699
|
+
return new Promise((resolve, reject) => {
|
|
700
|
+
const req = https.request(url, { method: "GET", headers: { Authorization: auth } }, (res) => {
|
|
701
|
+
let data = "";
|
|
702
|
+
res.on("data", (chunk) => data += chunk);
|
|
703
|
+
res.on("end", () => {
|
|
704
|
+
try {
|
|
705
|
+
const parsed = JSON.parse(data);
|
|
706
|
+
if (res.statusCode === 200 && parsed.data) {
|
|
707
|
+
resolve(parsed.data.map((t) => ({
|
|
708
|
+
id: t.id,
|
|
709
|
+
metrics: {
|
|
710
|
+
views: t.public_metrics?.impression_count || 0,
|
|
711
|
+
likes: t.public_metrics?.like_count || 0,
|
|
712
|
+
shares: (t.public_metrics?.retweet_count || 0) + (t.public_metrics?.quote_count || 0),
|
|
713
|
+
comments: t.public_metrics?.reply_count || 0
|
|
714
|
+
}
|
|
715
|
+
})));
|
|
716
|
+
} else {
|
|
717
|
+
reject(new Error(`Twitter API error (${res.statusCode}): ${data}`));
|
|
718
|
+
}
|
|
719
|
+
} catch {
|
|
720
|
+
reject(new Error(`Twitter API parse error: ${data}`));
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
req.on("error", reject);
|
|
725
|
+
req.end();
|
|
726
|
+
});
|
|
727
|
+
})).then((results) => results.flat());
|
|
728
|
+
}
|
|
729
|
+
function scheduleDir(notesDir) {
|
|
730
|
+
return path.join(notesDir, ".schedule");
|
|
731
|
+
}
|
|
732
|
+
function schedulePath(notesDir) {
|
|
733
|
+
return path.join(scheduleDir(notesDir), "queue.json");
|
|
734
|
+
}
|
|
735
|
+
function loadQueue(notesDir) {
|
|
736
|
+
const p = schedulePath(notesDir);
|
|
340
737
|
if (!fs.existsSync(p)) return [];
|
|
341
738
|
try {
|
|
342
739
|
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
@@ -344,134 +741,327 @@ function readTopics(notesDir) {
|
|
|
344
741
|
return [];
|
|
345
742
|
}
|
|
346
743
|
}
|
|
347
|
-
function
|
|
348
|
-
|
|
349
|
-
fs.
|
|
744
|
+
function saveQueue(notesDir, queue) {
|
|
745
|
+
const dir = scheduleDir(notesDir);
|
|
746
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
747
|
+
fs.writeFileSync(schedulePath(notesDir), JSON.stringify(queue, null, 2) + "\n", "utf8");
|
|
350
748
|
}
|
|
351
|
-
function
|
|
352
|
-
|
|
749
|
+
function postLinkedIn(text) {
|
|
750
|
+
const accessToken = process.env.LINKEDIN_ACCESS_TOKEN;
|
|
751
|
+
const personUrn = process.env.LINKEDIN_PERSON_URN;
|
|
752
|
+
if (!accessToken || !personUrn) {
|
|
753
|
+
return Promise.reject(
|
|
754
|
+
new Error(
|
|
755
|
+
"LinkedIn credentials not configured. Set LINKEDIN_ACCESS_TOKEN and LINKEDIN_PERSON_URN environment variables.\nRun: openclaw notes linkedin-auth"
|
|
756
|
+
)
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
const payload = JSON.stringify({
|
|
760
|
+
author: personUrn,
|
|
761
|
+
commentary: text,
|
|
762
|
+
visibility: "PUBLIC",
|
|
763
|
+
distribution: {
|
|
764
|
+
feedDistribution: "MAIN_FEED",
|
|
765
|
+
targetEntities: [],
|
|
766
|
+
thirdPartyDistributionChannels: []
|
|
767
|
+
},
|
|
768
|
+
lifecycleState: "PUBLISHED",
|
|
769
|
+
isReshareDisabledByAuthor: false
|
|
770
|
+
});
|
|
771
|
+
return new Promise((resolve, reject) => {
|
|
772
|
+
const req = https.request(
|
|
773
|
+
"https://api.linkedin.com/rest/posts",
|
|
774
|
+
{
|
|
775
|
+
method: "POST",
|
|
776
|
+
headers: {
|
|
777
|
+
Authorization: `Bearer ${accessToken}`,
|
|
778
|
+
"Content-Type": "application/json",
|
|
779
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
780
|
+
"LinkedIn-Version": "202401",
|
|
781
|
+
"X-Restli-Protocol-Version": "2.0.0"
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
(res) => {
|
|
785
|
+
let data = "";
|
|
786
|
+
res.on("data", (chunk) => data += chunk);
|
|
787
|
+
res.on("end", () => {
|
|
788
|
+
if (res.statusCode === 201) {
|
|
789
|
+
const postId = res.headers["x-restli-id"] || "";
|
|
790
|
+
resolve({ id: postId });
|
|
791
|
+
} else {
|
|
792
|
+
reject(new Error(`LinkedIn API error (${res.statusCode}): ${data}`));
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
);
|
|
797
|
+
req.on("error", reject);
|
|
798
|
+
req.write(payload);
|
|
799
|
+
req.end();
|
|
800
|
+
});
|
|
353
801
|
}
|
|
354
|
-
function
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
802
|
+
function exchangeLinkedInAuthCode(clientId, clientSecret, code, redirectUri) {
|
|
803
|
+
const body = [
|
|
804
|
+
`grant_type=authorization_code`,
|
|
805
|
+
`code=${encodeURIComponent(code)}`,
|
|
806
|
+
`client_id=${encodeURIComponent(clientId)}`,
|
|
807
|
+
`client_secret=${encodeURIComponent(clientSecret)}`,
|
|
808
|
+
`redirect_uri=${encodeURIComponent(redirectUri)}`
|
|
809
|
+
].join("&");
|
|
810
|
+
return new Promise((resolve, reject) => {
|
|
811
|
+
const req = https.request(
|
|
812
|
+
"https://www.linkedin.com/oauth/v2/accessToken",
|
|
813
|
+
{
|
|
814
|
+
method: "POST",
|
|
815
|
+
headers: {
|
|
816
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
817
|
+
"Content-Length": Buffer.byteLength(body)
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
(res) => {
|
|
821
|
+
let data = "";
|
|
822
|
+
res.on("data", (chunk) => data += chunk);
|
|
823
|
+
res.on("end", () => {
|
|
824
|
+
try {
|
|
825
|
+
const parsed = JSON.parse(data);
|
|
826
|
+
if (res.statusCode === 200 && parsed.access_token) {
|
|
827
|
+
resolve(parsed);
|
|
828
|
+
} else {
|
|
829
|
+
reject(
|
|
830
|
+
new Error(
|
|
831
|
+
`LinkedIn OAuth error (${res.statusCode}): ${parsed.error_description || parsed.error || data}`
|
|
832
|
+
)
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
reject(new Error(`LinkedIn OAuth parse error: ${data}`));
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
);
|
|
841
|
+
req.on("error", reject);
|
|
842
|
+
req.write(body);
|
|
843
|
+
req.end();
|
|
844
|
+
});
|
|
359
845
|
}
|
|
360
|
-
function
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
846
|
+
function fetchLinkedInPersonUrn(accessToken) {
|
|
847
|
+
return new Promise((resolve, reject) => {
|
|
848
|
+
const req = https.request(
|
|
849
|
+
"https://api.linkedin.com/v2/me",
|
|
850
|
+
{
|
|
851
|
+
method: "GET",
|
|
852
|
+
headers: {
|
|
853
|
+
Authorization: `Bearer ${accessToken}`,
|
|
854
|
+
"X-Restli-Protocol-Version": "2.0.0"
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
(res) => {
|
|
858
|
+
let data = "";
|
|
859
|
+
res.on("data", (chunk) => data += chunk);
|
|
860
|
+
res.on("end", () => {
|
|
861
|
+
try {
|
|
862
|
+
const parsed = JSON.parse(data);
|
|
863
|
+
if (res.statusCode === 200 && parsed.id) {
|
|
864
|
+
resolve(`urn:li:person:${parsed.id}`);
|
|
865
|
+
} else {
|
|
866
|
+
reject(new Error(`LinkedIn profile API error (${res.statusCode}): ${data}`));
|
|
867
|
+
}
|
|
868
|
+
} catch {
|
|
869
|
+
reject(new Error(`LinkedIn profile API parse error: ${data}`));
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
);
|
|
874
|
+
req.on("error", reject);
|
|
875
|
+
req.end();
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function personasDir(notesDir) {
|
|
879
|
+
return path.join(notesDir, ".personas");
|
|
880
|
+
}
|
|
881
|
+
function ensurePersonasDir(notesDir) {
|
|
882
|
+
const dir = personasDir(notesDir);
|
|
883
|
+
if (!fs.existsSync(dir)) {
|
|
884
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function loadInstalledPersonas(notesDir) {
|
|
888
|
+
const dir = personasDir(notesDir);
|
|
889
|
+
if (!fs.existsSync(dir)) return [];
|
|
890
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
891
|
+
const personas = [];
|
|
892
|
+
for (const file of files) {
|
|
893
|
+
try {
|
|
894
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
895
|
+
personas.push(data);
|
|
896
|
+
} catch {
|
|
374
897
|
}
|
|
375
898
|
}
|
|
376
|
-
|
|
377
|
-
return topics;
|
|
899
|
+
return personas;
|
|
378
900
|
}
|
|
379
|
-
function
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
901
|
+
function loadInstalledPersona(notesDir, name) {
|
|
902
|
+
const file = path.join(personasDir(notesDir), `${name}.json`);
|
|
903
|
+
if (!fs.existsSync(file)) return null;
|
|
904
|
+
try {
|
|
905
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
906
|
+
} catch {
|
|
907
|
+
return null;
|
|
385
908
|
}
|
|
386
|
-
return slug;
|
|
387
909
|
}
|
|
388
|
-
function
|
|
389
|
-
return
|
|
910
|
+
function validatePersona(data) {
|
|
911
|
+
return typeof data === "object" && data !== null && typeof data.name === "string" && typeof data.displayName === "string" && typeof data.description === "string" && typeof data.version === "string" && typeof data.author === "string" && typeof data.style === "object" && data.style !== null && typeof data.style.tone === "string" && Array.isArray(data.style.patterns) && Array.isArray(data.examples) && Array.isArray(data.tags);
|
|
390
912
|
}
|
|
391
|
-
function
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
913
|
+
function fetchJson(url) {
|
|
914
|
+
return new Promise((resolve, reject) => {
|
|
915
|
+
const parsedUrl = new URL(url);
|
|
916
|
+
const options = {
|
|
917
|
+
hostname: parsedUrl.hostname,
|
|
918
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
919
|
+
method: "GET",
|
|
920
|
+
headers: { "Accept": "application/json", "User-Agent": "pressclaw/1.0" }
|
|
921
|
+
};
|
|
922
|
+
const req = https.request(options, (res) => {
|
|
923
|
+
let data = "";
|
|
924
|
+
res.on("data", (chunk) => data += chunk);
|
|
925
|
+
res.on("end", () => {
|
|
926
|
+
if (res.statusCode !== 200) {
|
|
927
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
resolve(JSON.parse(data));
|
|
932
|
+
} catch {
|
|
933
|
+
reject(new Error(`Invalid JSON response from ${url}`));
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
req.on("error", reject);
|
|
938
|
+
req.setTimeout(1e4, () => {
|
|
939
|
+
req.destroy();
|
|
940
|
+
reject(new Error("Request timed out"));
|
|
941
|
+
});
|
|
942
|
+
req.end();
|
|
943
|
+
});
|
|
399
944
|
}
|
|
400
|
-
function
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
} else {
|
|
420
|
-
blocks.push(`<pre><code${codeLang ? ` class="language-${escapeHtml(codeLang)}"` : ""}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
421
|
-
inCodeBlock = false;
|
|
422
|
-
codeLines = [];
|
|
423
|
-
codeLang = "";
|
|
945
|
+
function slugifyPersonaName(input) {
|
|
946
|
+
return input.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
947
|
+
}
|
|
948
|
+
function updatePerformanceWeights(notesDir) {
|
|
949
|
+
const profilePath = path.join(notesDir, ".style-profile.json");
|
|
950
|
+
if (!fs.existsSync(profilePath)) return;
|
|
951
|
+
const allFeedback = loadAllFeedback(notesDir);
|
|
952
|
+
if (allFeedback.length < 2) return;
|
|
953
|
+
const noteScores = [];
|
|
954
|
+
for (const fb of allFeedback) {
|
|
955
|
+
if (fb.aggregate.entries === 0) continue;
|
|
956
|
+
const noteFile = path.join(notesDir, `${fb.slug}.md`);
|
|
957
|
+
if (!fs.existsSync(noteFile)) continue;
|
|
958
|
+
const { meta, body } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
|
|
959
|
+
let markers = null;
|
|
960
|
+
if (meta.style_markers) {
|
|
961
|
+
try {
|
|
962
|
+
markers = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers;
|
|
963
|
+
} catch {
|
|
424
964
|
}
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (inCodeBlock) {
|
|
428
|
-
codeLines.push(rawLine);
|
|
429
|
-
continue;
|
|
430
965
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
966
|
+
if (!markers) markers = extractStyleMarkers(body);
|
|
967
|
+
noteScores.push({
|
|
968
|
+
slug: fb.slug,
|
|
969
|
+
score: fb.aggregate.avgScore,
|
|
970
|
+
tone: meta.tone || null,
|
|
971
|
+
structure: meta.structure || null,
|
|
972
|
+
markers
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
if (noteScores.length < 2) return;
|
|
976
|
+
const toneScores = {};
|
|
977
|
+
for (const n of noteScores) {
|
|
978
|
+
if (!n.tone) continue;
|
|
979
|
+
if (!toneScores[n.tone]) toneScores[n.tone] = { total: 0, count: 0 };
|
|
980
|
+
toneScores[n.tone].total += n.score;
|
|
981
|
+
toneScores[n.tone].count += 1;
|
|
982
|
+
}
|
|
983
|
+
const preferredTone = Object.entries(toneScores).map(([tone, d]) => ({ tone, avg: d.total / d.count })).sort((a, b) => b.avg - a.avg)[0]?.tone || "casual";
|
|
984
|
+
const structScores = {};
|
|
985
|
+
for (const n of noteScores) {
|
|
986
|
+
if (!n.structure) continue;
|
|
987
|
+
if (!structScores[n.structure]) structScores[n.structure] = { total: 0, count: 0 };
|
|
988
|
+
structScores[n.structure].total += n.score;
|
|
989
|
+
structScores[n.structure].count += 1;
|
|
990
|
+
}
|
|
991
|
+
const preferredStructure = Object.entries(structScores).map(([structure, d]) => ({ structure, avg: d.total / d.count })).sort((a, b) => b.avg - a.avg)[0]?.structure || "chunky";
|
|
992
|
+
const sorted = [...noteScores].sort((a, b) => b.score - a.score);
|
|
993
|
+
const topHalf = sorted.slice(0, Math.max(2, Math.ceil(sorted.length / 2)));
|
|
994
|
+
const sentLengths = topHalf.filter((n) => n.markers?.avgSentenceLength).map((n) => n.markers.avgSentenceLength);
|
|
995
|
+
const wordCounts = topHalf.filter((n) => n.markers?.wordCount).map((n) => n.markers.wordCount);
|
|
996
|
+
const optimalSentenceLength = sentLengths.length > 0 ? [Math.round(Math.min(...sentLengths)), Math.round(Math.max(...sentLengths))] : [7, 15];
|
|
997
|
+
const optimalWordCount = wordCounts.length > 0 ? [Math.round(Math.min(...wordCounts)), Math.round(Math.max(...wordCounts))] : [200, 500];
|
|
998
|
+
let profile;
|
|
999
|
+
try {
|
|
1000
|
+
profile = JSON.parse(fs.readFileSync(profilePath, "utf8"));
|
|
1001
|
+
} catch {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
profile.performanceWeights = {
|
|
1005
|
+
preferredTone,
|
|
1006
|
+
preferredStructure,
|
|
1007
|
+
optimalSentenceLength,
|
|
1008
|
+
optimalWordCount,
|
|
1009
|
+
sampleSize: noteScores.length
|
|
1010
|
+
};
|
|
1011
|
+
fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2) + "\n", "utf8");
|
|
1012
|
+
}
|
|
1013
|
+
function topicsPath(notesDir) {
|
|
1014
|
+
return path.join(notesDir, "topics.json");
|
|
1015
|
+
}
|
|
1016
|
+
function readTopics(notesDir) {
|
|
1017
|
+
const p = topicsPath(notesDir);
|
|
1018
|
+
if (!fs.existsSync(p)) return [];
|
|
1019
|
+
try {
|
|
1020
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
1021
|
+
} catch {
|
|
1022
|
+
return [];
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function writeTopics(notesDir, topics) {
|
|
1026
|
+
fs.mkdirSync(notesDir, { recursive: true });
|
|
1027
|
+
fs.writeFileSync(topicsPath(notesDir), JSON.stringify(topics, null, 2) + "\n", "utf8");
|
|
1028
|
+
}
|
|
1029
|
+
function generateId() {
|
|
1030
|
+
return Math.random().toString(36).slice(2, 10);
|
|
1031
|
+
}
|
|
1032
|
+
function findTopicByIdOrTitle(topics, query) {
|
|
1033
|
+
const byId = topics.find((t) => t.id === query);
|
|
1034
|
+
if (byId) return byId;
|
|
1035
|
+
const lower = query.toLowerCase();
|
|
1036
|
+
return topics.find((t) => t.title.toLowerCase() === lower) || topics.find((t) => t.title.toLowerCase().startsWith(lower));
|
|
1037
|
+
}
|
|
1038
|
+
function syncTopicStatus(notesDir, topics) {
|
|
1039
|
+
let changed = false;
|
|
1040
|
+
for (const topic of topics) {
|
|
1041
|
+
if (!topic.slug) continue;
|
|
1042
|
+
const noteFile = path.join(notesDir, `${topic.slug}.md`);
|
|
1043
|
+
if (!fs.existsSync(noteFile)) continue;
|
|
1044
|
+
const { meta } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
|
|
1045
|
+
let noteStatus;
|
|
1046
|
+
if (meta.status === "public") noteStatus = "published";
|
|
1047
|
+
else if (meta.status === "refined") noteStatus = "refined";
|
|
1048
|
+
else noteStatus = "drafted";
|
|
1049
|
+
if (topic.status !== noteStatus) {
|
|
1050
|
+
topic.status = noteStatus;
|
|
1051
|
+
changed = true;
|
|
465
1052
|
}
|
|
466
|
-
para.push(escapeHtml(line));
|
|
467
1053
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
html = html.replace(/(<li>.*?<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
|
|
471
|
-
return html;
|
|
1054
|
+
if (changed) writeTopics(notesDir, topics);
|
|
1055
|
+
return topics;
|
|
472
1056
|
}
|
|
473
|
-
function
|
|
474
|
-
|
|
1057
|
+
function uniqueSlug(base, notesDir) {
|
|
1058
|
+
let slug = base;
|
|
1059
|
+
let i = 2;
|
|
1060
|
+
while (fs.existsSync(path.join(notesDir, `${slug}.md`))) {
|
|
1061
|
+
slug = `${base}-${i}`;
|
|
1062
|
+
i += 1;
|
|
1063
|
+
}
|
|
1064
|
+
return slug;
|
|
475
1065
|
}
|
|
476
1066
|
async function confirmPublishPrompt(title, excerpt) {
|
|
477
1067
|
if (!process.stdin.isTTY) {
|
|
@@ -501,19 +1091,14 @@ function buildPublic({ notesDir, outputDir, publicPath, siteTitle, authorName, b
|
|
|
501
1091
|
const slug = meta.slug || slugify(title);
|
|
502
1092
|
const date = meta.published_at || (/* @__PURE__ */ new Date()).toUTCString();
|
|
503
1093
|
const excerpt = excerptFrom(body);
|
|
504
|
-
const url = `${publicPath}/${slug}`;
|
|
505
1094
|
const bodyHtml = renderMarkdown(body);
|
|
506
|
-
items.push({ title,
|
|
507
|
-
const html =
|
|
1095
|
+
items.push({ title, slug, date, excerpt, bodyHtml });
|
|
1096
|
+
const html = renderPostPage(title, bodyHtml, { siteTitle, authorName, date });
|
|
508
1097
|
fs.writeFileSync(path.join(outputDir, "posts", `${slug}.html`), html, "utf8");
|
|
509
1098
|
}
|
|
510
|
-
const indexHtml =
|
|
1099
|
+
const indexHtml = renderIndexPage(items, { siteTitle, baseUrl });
|
|
511
1100
|
fs.writeFileSync(path.join(outputDir, "index.html"), indexHtml, "utf8");
|
|
512
|
-
const
|
|
513
|
-
...i,
|
|
514
|
-
url: baseUrl ? `${baseUrl}${i.url}` : i.url
|
|
515
|
-
}));
|
|
516
|
-
const rss = rssTemplate(rssItems, { siteTitle, baseUrl });
|
|
1101
|
+
const rss = renderRss(items, { siteTitle, baseUrl: baseUrl || "" });
|
|
517
1102
|
fs.writeFileSync(path.join(outputDir, "rss.xml"), rss, "utf8");
|
|
518
1103
|
}
|
|
519
1104
|
function newNoteFrontmatter(opts) {
|
|
@@ -536,6 +1121,130 @@ tags: ${tags}
|
|
|
536
1121
|
`;
|
|
537
1122
|
return fm;
|
|
538
1123
|
}
|
|
1124
|
+
function parseTags(raw) {
|
|
1125
|
+
if (!raw) return [];
|
|
1126
|
+
if (Array.isArray(raw)) return raw;
|
|
1127
|
+
if (typeof raw === "string") {
|
|
1128
|
+
const trimmed = raw.trim();
|
|
1129
|
+
if (trimmed.startsWith("[")) {
|
|
1130
|
+
try {
|
|
1131
|
+
return JSON.parse(trimmed);
|
|
1132
|
+
} catch {
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return trimmed.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1136
|
+
}
|
|
1137
|
+
return [];
|
|
1138
|
+
}
|
|
1139
|
+
function parseStyleMarkers(raw) {
|
|
1140
|
+
if (!raw) return null;
|
|
1141
|
+
if (typeof raw === "object" && !Array.isArray(raw)) return raw;
|
|
1142
|
+
if (typeof raw === "string") {
|
|
1143
|
+
try {
|
|
1144
|
+
return JSON.parse(raw);
|
|
1145
|
+
} catch {
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
function generateSummary(body) {
|
|
1151
|
+
const plain = body.replace(/^#+\s+.*/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`[^`]+`/g, "").replace(/```[\s\S]*?```/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/^>\s+/gm, "").replace(/^[-*]\s+/gm, "").replace(/^\d+\.\s+/gm, "").replace(/\n+/g, " ").trim();
|
|
1152
|
+
const sentences = plain.match(/[^.!?]*[.!?]/g);
|
|
1153
|
+
if (sentences && sentences.length >= 2) {
|
|
1154
|
+
const twoSentences = sentences.slice(0, 2).join("").trim();
|
|
1155
|
+
if (twoSentences.length <= 300) return twoSentences;
|
|
1156
|
+
}
|
|
1157
|
+
return plain.slice(0, 200).trim() + (plain.length > 200 ? "\u2026" : "");
|
|
1158
|
+
}
|
|
1159
|
+
function buildAgentFeed(cfg) {
|
|
1160
|
+
const files = listNotes(cfg.notesDir);
|
|
1161
|
+
const posts = [];
|
|
1162
|
+
for (const file of files) {
|
|
1163
|
+
const full = fs.readFileSync(path.join(cfg.notesDir, file), "utf8");
|
|
1164
|
+
const { meta, body } = parseFrontMatter(full);
|
|
1165
|
+
if (meta.status !== "public") continue;
|
|
1166
|
+
const slug = meta.slug || file.replace(/\.md$/, "");
|
|
1167
|
+
const title = meta.title || slug;
|
|
1168
|
+
const tags = parseTags(meta.tags);
|
|
1169
|
+
const sm = parseStyleMarkers(meta.style_markers);
|
|
1170
|
+
const conf = meta.confidence ? parseFloat(meta.confidence) : null;
|
|
1171
|
+
posts.push({
|
|
1172
|
+
slug,
|
|
1173
|
+
title,
|
|
1174
|
+
published_at: meta.published_at || (/* @__PURE__ */ new Date()).toISOString(),
|
|
1175
|
+
url: `${cfg.baseUrl}${cfg.publicPath}/${slug}`,
|
|
1176
|
+
body_markdown: body,
|
|
1177
|
+
summary: generateSummary(body),
|
|
1178
|
+
topics: tags,
|
|
1179
|
+
// derive from tags for now
|
|
1180
|
+
tags,
|
|
1181
|
+
style_markers: sm ? {
|
|
1182
|
+
tone: meta.tone || sm.tone || "authentic",
|
|
1183
|
+
structure: meta.structure || sm.structure || "structured",
|
|
1184
|
+
readability: sm.readabilityScore ?? sm.readability ?? null,
|
|
1185
|
+
wordCount: sm.wordCount ?? null,
|
|
1186
|
+
avgSentenceLength: sm.avgSentenceLength ?? null,
|
|
1187
|
+
perspective: sm.perspective ?? "mixed"
|
|
1188
|
+
} : null,
|
|
1189
|
+
confidence: isNaN(conf) ? null : conf,
|
|
1190
|
+
related: [],
|
|
1191
|
+
reply_to: meta.reply_to || null,
|
|
1192
|
+
conversation_id: meta.conversation_id || null
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
posts.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());
|
|
1196
|
+
const updatedAt = posts.length > 0 ? posts[0].published_at : (/* @__PURE__ */ new Date()).toISOString();
|
|
1197
|
+
return {
|
|
1198
|
+
version: "0.4",
|
|
1199
|
+
protocol: "pressclaw-agent-feed",
|
|
1200
|
+
source: {
|
|
1201
|
+
name: cfg.siteTitle,
|
|
1202
|
+
url: cfg.baseUrl,
|
|
1203
|
+
pressclaw_version: "0.3.0",
|
|
1204
|
+
feed_url: `${cfg.baseUrl}/feed/agent.json`
|
|
1205
|
+
},
|
|
1206
|
+
updated_at: updatedAt,
|
|
1207
|
+
posts
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function buildWellKnown(cfg) {
|
|
1211
|
+
const feed = buildAgentFeed(cfg);
|
|
1212
|
+
const allTopics = /* @__PURE__ */ new Set();
|
|
1213
|
+
for (const post of feed.posts) {
|
|
1214
|
+
for (const t of post.topics) allTopics.add(t);
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
version: "0.4",
|
|
1218
|
+
name: cfg.siteTitle,
|
|
1219
|
+
feed_url: `${cfg.baseUrl}/feed/agent.json`,
|
|
1220
|
+
human_url: `${cfg.baseUrl}${cfg.publicPath}`,
|
|
1221
|
+
rss_url: `${cfg.baseUrl}${cfg.publicPath}/rss.xml`,
|
|
1222
|
+
post_count: feed.posts.length,
|
|
1223
|
+
topics: Array.from(allTopics),
|
|
1224
|
+
updated_at: feed.updated_at
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
function subscriptionsPath(notesDir) {
|
|
1228
|
+
return path.join(notesDir, ".subscriptions.json");
|
|
1229
|
+
}
|
|
1230
|
+
function readSubscriptions(notesDir) {
|
|
1231
|
+
const p = subscriptionsPath(notesDir);
|
|
1232
|
+
if (!fs.existsSync(p)) return [];
|
|
1233
|
+
try {
|
|
1234
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
1235
|
+
return data.subscriptions || [];
|
|
1236
|
+
} catch {
|
|
1237
|
+
return [];
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
function writeSubscriptions(notesDir, subs) {
|
|
1241
|
+
fs.mkdirSync(notesDir, { recursive: true });
|
|
1242
|
+
fs.writeFileSync(
|
|
1243
|
+
subscriptionsPath(notesDir),
|
|
1244
|
+
JSON.stringify({ subscriptions: subs }, null, 2) + "\n",
|
|
1245
|
+
"utf8"
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
539
1248
|
function register(api) {
|
|
540
1249
|
const pluginDir = __dirname;
|
|
541
1250
|
api.registerCli(({ program }) => {
|
|
@@ -644,9 +1353,9 @@ Next steps:`);
|
|
|
644
1353
|
try {
|
|
645
1354
|
const cronList = await api.cron?.list?.();
|
|
646
1355
|
if (cronList && Array.isArray(cronList)) {
|
|
647
|
-
const existing = cronList.find((j) => j.name === "
|
|
1356
|
+
const existing = cronList.find((j) => j.name === "pressclaw-daily");
|
|
648
1357
|
if (existing) {
|
|
649
|
-
console.log("\u2705 Cron job '
|
|
1358
|
+
console.log("\u2705 Cron job 'pressclaw-daily' already exists. Skipping.");
|
|
650
1359
|
console.log(` Schedule: ${existing.schedule || existing.cron || cfg.dailyPrompt.schedule}`);
|
|
651
1360
|
return;
|
|
652
1361
|
}
|
|
@@ -655,20 +1364,20 @@ Next steps:`);
|
|
|
655
1364
|
}
|
|
656
1365
|
try {
|
|
657
1366
|
await api.cron?.create?.({
|
|
658
|
-
name: "
|
|
1367
|
+
name: "pressclaw-daily",
|
|
659
1368
|
schedule: cfg.dailyPrompt.schedule,
|
|
660
1369
|
timezone: cfg.dailyPrompt.timezone,
|
|
661
1370
|
prompt: cfg.dailyPrompt.prompt
|
|
662
1371
|
});
|
|
663
1372
|
console.log(`\u2705 Daily prompt cron job created!`);
|
|
664
|
-
console.log(` Name:
|
|
1373
|
+
console.log(` Name: pressclaw-daily`);
|
|
665
1374
|
console.log(` Schedule: ${cfg.dailyPrompt.schedule}`);
|
|
666
1375
|
console.log(` Timezone: ${cfg.dailyPrompt.timezone}`);
|
|
667
1376
|
} catch (err) {
|
|
668
1377
|
console.error(`Failed to create cron job via API. You can create it manually:
|
|
669
1378
|
`);
|
|
670
1379
|
console.log(` openclaw cron create \\`);
|
|
671
|
-
console.log(` --name "
|
|
1380
|
+
console.log(` --name "pressclaw-daily" \\`);
|
|
672
1381
|
console.log(` --schedule "${cfg.dailyPrompt.schedule}" \\`);
|
|
673
1382
|
console.log(` --timezone "${cfg.dailyPrompt.timezone}" \\`);
|
|
674
1383
|
console.log(` --prompt '${cfg.dailyPrompt.prompt.replace(/'/g, "'\\''")}'`);
|
|
@@ -760,7 +1469,19 @@ ${body}
|
|
|
760
1469
|
notes.command("build").action(() => {
|
|
761
1470
|
const cfg = resolveConfig(api);
|
|
762
1471
|
buildPublic(cfg);
|
|
763
|
-
|
|
1472
|
+
if (cfg.baseUrl) {
|
|
1473
|
+
const feedDir = path.join(cfg.outputDir, "feed");
|
|
1474
|
+
fs.mkdirSync(feedDir, { recursive: true });
|
|
1475
|
+
const agentFeed = buildAgentFeed(cfg);
|
|
1476
|
+
fs.writeFileSync(path.join(feedDir, "agent.json"), JSON.stringify(agentFeed, null, 2) + "\n", "utf8");
|
|
1477
|
+
const wellKnownDir = path.join(cfg.outputDir, ".well-known");
|
|
1478
|
+
fs.mkdirSync(wellKnownDir, { recursive: true });
|
|
1479
|
+
const wellKnown = buildWellKnown(cfg);
|
|
1480
|
+
fs.writeFileSync(path.join(wellKnownDir, "pressclaw.json"), JSON.stringify(wellKnown, null, 2) + "\n", "utf8");
|
|
1481
|
+
console.log("Built public stream + RSS + agent feed");
|
|
1482
|
+
} else {
|
|
1483
|
+
console.log("Built public stream + RSS (set baseUrl in config for agent feed)");
|
|
1484
|
+
}
|
|
764
1485
|
});
|
|
765
1486
|
notes.command("list").action(() => {
|
|
766
1487
|
const cfg = resolveConfig(api);
|
|
@@ -1044,7 +1765,7 @@ To add a topic from this scan:
|
|
|
1044
1765
|
console.log(`After transforming, update the note file (set tone: ${tone}, structure: ${structure} in frontmatter) and run:`);
|
|
1045
1766
|
console.log(` openclaw notes refine ${slug}`);
|
|
1046
1767
|
});
|
|
1047
|
-
notes.command("adapt <slug>").option("--platform <platform>", "target platform: linkedin | twitter | thread", "linkedin").action((slug, options) => {
|
|
1768
|
+
notes.command("adapt <slug>").option("--platform <platform>", "target platform: linkedin | twitter | thread | reddit", "linkedin").option("--subreddit <subreddit>", "target subreddit for reddit (e.g. r/SaaS)", "r/startups").action((slug, options) => {
|
|
1048
1769
|
const cfg = resolveConfig(api);
|
|
1049
1770
|
const file = path.join(cfg.notesDir, `${slug}.md`);
|
|
1050
1771
|
if (!fs.existsSync(file)) {
|
|
@@ -1150,58 +1871,869 @@ Your job isn't to argue percentages. It's to control the frame.`
|
|
|
1150
1871
|
}
|
|
1151
1872
|
};
|
|
1152
1873
|
const spec = platformSpecs[platform];
|
|
1153
|
-
if (!spec) {
|
|
1874
|
+
if (!spec && platform !== "reddit") {
|
|
1154
1875
|
console.log(`Unknown platform: ${platform}`);
|
|
1155
|
-
console.log(`Available: ${Object.keys(platformSpecs).join(", ")}`);
|
|
1876
|
+
console.log(`Available: ${Object.keys(platformSpecs).join(", ")}, reddit`);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const varsPath = path.join(cfg.notesDir, ".variations", slug);
|
|
1880
|
+
fs.mkdirSync(varsPath, { recursive: true });
|
|
1881
|
+
if (platform === "reddit") {
|
|
1882
|
+
const subreddit = (options.subreddit || "r/startups").replace(/^(?!r\/)/, "r/");
|
|
1883
|
+
const subGuideline = SUBREDDIT_GUIDELINES[subreddit] || DEFAULT_SUBREDDIT_GUIDELINE;
|
|
1884
|
+
const knownSubs = KNOWN_SUBREDDITS;
|
|
1885
|
+
const redditFile = `reddit-${subreddit.replace(/^r\//, "")}.md`;
|
|
1886
|
+
console.log(`\u{1F4F1} Adapt for Reddit (${subreddit}): "${title}"`);
|
|
1887
|
+
console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
|
|
1888
|
+
console.log(`Platform: Reddit (${subreddit})`);
|
|
1889
|
+
console.log(`Format: text post with Title, Body, TL;DR`);
|
|
1890
|
+
console.log(`Source: ${wordCount} words
|
|
1891
|
+
`);
|
|
1892
|
+
console.log(`Original blog post:`);
|
|
1893
|
+
console.log(body);
|
|
1894
|
+
console.log();
|
|
1895
|
+
console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
|
|
1896
|
+
console.log(`Adapt this blog post into a Reddit text post for ${subreddit}.
|
|
1897
|
+
`);
|
|
1898
|
+
console.log(`\u{1F4CB} SUBREDDIT GUIDELINES for ${subreddit}:`);
|
|
1899
|
+
console.log(` ${subGuideline}`);
|
|
1900
|
+
if (!SUBREDDIT_GUIDELINES[subreddit]) {
|
|
1901
|
+
console.log(` (No specific guidelines found \u2014 using defaults. Known subreddits: ${knownSubs.join(", ")})`);
|
|
1902
|
+
}
|
|
1903
|
+
console.log();
|
|
1904
|
+
console.log(`\u{1F6AB} CRITICAL ANTI-PROMOTION RULES \u2014 violating these gets the post removed or downvoted:`);
|
|
1905
|
+
console.log(` \u2022 NEVER include a URL or link anywhere in the post`);
|
|
1906
|
+
console.log(` \u2022 NEVER include a call-to-action ("check out", "try", "sign up", "visit")`);
|
|
1907
|
+
console.log(` \u2022 Mention the product/company at most ONCE, as brief context ("I run a small SaaS for X"), never as a pitch`);
|
|
1908
|
+
console.log(` \u2022 The post MUST provide standalone value \u2014 if you removed the product mention entirely, the post should still be worth reading`);
|
|
1909
|
+
console.log(` \u2022 Write as a person sharing an experience, NOT a company making an announcement`);
|
|
1910
|
+
console.log(` \u2022 Use "I" not "we" \u2014 personal stories perform better on Reddit`);
|
|
1911
|
+
console.log(` \u2022 No marketing language: "excited to announce", "game-changer", "revolutionary", "check this out"`);
|
|
1912
|
+
console.log();
|
|
1913
|
+
console.log(`\u{1F4D0} STRUCTURE:`);
|
|
1914
|
+
console.log(` Title \u2192 Body (300-800 words) \u2192 TL;DR (1-2 sentences)`);
|
|
1915
|
+
console.log();
|
|
1916
|
+
console.log(`\u{1F4DD} TITLE RULES:`);
|
|
1917
|
+
console.log(` \u2022 Conversational, first-person ("I learned X" not "Why X Matters")`);
|
|
1918
|
+
console.log(` \u2022 No emojis in title`);
|
|
1919
|
+
console.log(` \u2022 Curiosity or relatability hook`);
|
|
1920
|
+
console.log(` \u2022 Under 150 characters`);
|
|
1921
|
+
console.log();
|
|
1922
|
+
console.log(`\u{1F4DD} BODY RULES:`);
|
|
1923
|
+
console.log(` \u2022 300-800 words`);
|
|
1924
|
+
console.log(` \u2022 Short paragraphs (2-3 sentences max)`);
|
|
1925
|
+
console.log(` \u2022 Story-first: open with the experience, not the lesson`);
|
|
1926
|
+
console.log(` \u2022 Bold (**) for 2-3 key phrases only`);
|
|
1927
|
+
console.log(` \u2022 No headers (Reddit text posts look weird with markdown headers)`);
|
|
1928
|
+
console.log(` \u2022 TL;DR at the very end (1-2 sentences)`);
|
|
1929
|
+
console.log();
|
|
1930
|
+
if (namedPersona) {
|
|
1931
|
+
console.log(`\u{1F3AD} VOICE (${namedPersona.name}):`);
|
|
1932
|
+
console.log(` ${namedPersona.voiceDescription}`);
|
|
1933
|
+
if (namedPersona.avoid?.length) console.log(` Avoid: ${namedPersona.avoid.slice(0, 5).join(", ")}`);
|
|
1934
|
+
console.log();
|
|
1935
|
+
} else if (styleProfile?.voiceDescription) {
|
|
1936
|
+
console.log(`\u{1F3A8} VOICE (your style profile):`);
|
|
1937
|
+
console.log(` ${styleProfile.voiceDescription}`);
|
|
1938
|
+
console.log();
|
|
1939
|
+
}
|
|
1940
|
+
console.log(`Output Instructions:`);
|
|
1941
|
+
console.log(`- Distill the core insight \u2014 don't try to compress the whole post`);
|
|
1942
|
+
console.log(`- Match Reddit's native feel \u2014 it should read like a genuine ${subreddit} post, not a repurposed blog`);
|
|
1943
|
+
console.log(`- Preserve the author's voice and perspective`);
|
|
1944
|
+
console.log(`- The input may mention a specific product. Genericize it to "my SaaS" or "my startup" or "a tool I built" \u2014 never use the product name`);
|
|
1945
|
+
console.log();
|
|
1946
|
+
console.log(`After adapting, save the result to: ${varsPath}/${redditFile}`);
|
|
1947
|
+
console.log(`Format the file with clear sections:`);
|
|
1948
|
+
console.log(` # Title`);
|
|
1949
|
+
console.log(` <the post title>`);
|
|
1950
|
+
console.log();
|
|
1951
|
+
console.log(` # Body`);
|
|
1952
|
+
console.log(` <the post body in Reddit markdown>`);
|
|
1953
|
+
console.log();
|
|
1954
|
+
console.log(` # TL;DR`);
|
|
1955
|
+
console.log(` <1-2 sentence summary>`);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
console.log(`\u{1F4F1} Adapt for ${spec.name}: "${title}"`);
|
|
1959
|
+
console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
|
|
1960
|
+
console.log(`Platform: ${spec.name} (${spec.format})`);
|
|
1961
|
+
console.log(`Max chars: ${spec.maxChars}`);
|
|
1962
|
+
console.log(`Source: ${wordCount} words
|
|
1963
|
+
`);
|
|
1964
|
+
console.log(`Original blog post:`);
|
|
1965
|
+
console.log(body);
|
|
1966
|
+
console.log();
|
|
1967
|
+
console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
|
|
1968
|
+
console.log(`Adapt this blog post into a ${spec.name} ${spec.format}.
|
|
1969
|
+
`);
|
|
1970
|
+
console.log(`\u{1F4CB} PLATFORM RULES for ${spec.name}:`);
|
|
1971
|
+
for (const c of spec.conventions) console.log(` \u2022 ${c}`);
|
|
1972
|
+
console.log();
|
|
1973
|
+
console.log(`\u{1F4D0} STRUCTURE: ${spec.structure}`);
|
|
1974
|
+
console.log();
|
|
1975
|
+
console.log(`\u{1F4DD} EXAMPLE of good ${spec.name} ${spec.format} on similar topic:`);
|
|
1976
|
+
console.log(` ${spec.example.split("\n").join("\n ")}`);
|
|
1977
|
+
console.log();
|
|
1978
|
+
if (spec.maxChars <= 280) {
|
|
1979
|
+
console.log(`\u26A0\uFE0F CHARACTER LIMIT: ${spec.maxChars} chars. Count carefully. Every word must earn its place.`);
|
|
1980
|
+
console.log();
|
|
1981
|
+
}
|
|
1982
|
+
if (namedPersona) {
|
|
1983
|
+
console.log(`\u{1F3AD} VOICE (${namedPersona.name}):`);
|
|
1984
|
+
console.log(` ${namedPersona.voiceDescription}`);
|
|
1985
|
+
if (namedPersona.avoid?.length) console.log(` Avoid: ${namedPersona.avoid.slice(0, 5).join(", ")}`);
|
|
1986
|
+
console.log();
|
|
1987
|
+
} else if (styleProfile?.voiceDescription) {
|
|
1988
|
+
console.log(`\u{1F3A8} VOICE (your style profile):`);
|
|
1989
|
+
console.log(` ${styleProfile.voiceDescription}`);
|
|
1990
|
+
console.log();
|
|
1991
|
+
}
|
|
1992
|
+
console.log(`Output Instructions:`);
|
|
1993
|
+
console.log(`- Distill the core insight \u2014 don't try to compress the whole post`);
|
|
1994
|
+
console.log(`- Match the platform's native feel \u2014 it should look like it was written for ${spec.name}, not copy-pasted from a blog`);
|
|
1995
|
+
console.log(`- Preserve the author's voice and perspective`);
|
|
1996
|
+
if (platform === "thread") {
|
|
1997
|
+
console.log(`- Output each tweet on its own line, prefixed with the number (1/, 2/, etc.)`);
|
|
1998
|
+
console.log(`- Separate tweets with a blank line`);
|
|
1999
|
+
}
|
|
2000
|
+
console.log();
|
|
2001
|
+
console.log(`After adapting, save the result to: ${varsPath}/${platform}.md`);
|
|
2002
|
+
console.log(`Format: plain text (no markdown), ready to copy-paste into ${spec.name}.`);
|
|
2003
|
+
});
|
|
2004
|
+
notes.command("tweet <slug>").option("--dry-run", "preview only, don't post").option("--yes", "skip confirmation").action(async (slug, options) => {
|
|
2005
|
+
const cfg = resolveConfig(api);
|
|
2006
|
+
const twitterPath = path.join(cfg.notesDir, ".variations", slug, "twitter.md");
|
|
2007
|
+
if (!fs.existsSync(twitterPath)) {
|
|
2008
|
+
console.log(`No twitter adaptation found for "${slug}".`);
|
|
2009
|
+
console.log(`Run: openclaw notes adapt ${slug} --platform twitter`);
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
const tweetText = fs.readFileSync(twitterPath, "utf8").trim();
|
|
2013
|
+
if (tweetText.length > 280) {
|
|
2014
|
+
console.log(`\u26A0\uFE0F Tweet is ${tweetText.length} chars (max 280). Edit ${twitterPath} to shorten.`);
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
console.log(`
|
|
2018
|
+
\u{1F426} Tweet preview (${tweetText.length}/280 chars):
|
|
2019
|
+
`);
|
|
2020
|
+
console.log(` ${tweetText}
|
|
2021
|
+
`);
|
|
2022
|
+
if (options.dryRun) {
|
|
2023
|
+
console.log("(dry run \u2014 not posting)");
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (!options.yes) {
|
|
2027
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2028
|
+
const answer = await rl.question("Post to @pressclawai? (y/n) ");
|
|
2029
|
+
rl.close();
|
|
2030
|
+
if (answer.toLowerCase() !== "y") {
|
|
2031
|
+
console.log("Cancelled.");
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
try {
|
|
2036
|
+
const result = await postTweet(tweetText);
|
|
2037
|
+
console.log(`\u2705 Posted! https://x.com/pressclawai/status/${result.id}`);
|
|
2038
|
+
let fb = loadFeedback(cfg.notesDir, slug);
|
|
2039
|
+
if (!fb) {
|
|
2040
|
+
fb = { slug, entries: [], tweets: [], aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 } };
|
|
2041
|
+
}
|
|
2042
|
+
if (!fb.tweets) fb.tweets = [];
|
|
2043
|
+
fb.tweets.push({ id: result.id, text: result.text, postedAt: (/* @__PURE__ */ new Date()).toISOString(), platform: "twitter" });
|
|
2044
|
+
saveFeedback(cfg.notesDir, fb);
|
|
2045
|
+
console.log(`\u{1F4CE} Tweet ID saved to .feedback/${slug}.json`);
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
console.log(`\u274C Failed to post: ${err.message}`);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
notes.command("engagement [slug]").action(async (slug) => {
|
|
2051
|
+
const cfg = resolveConfig(api);
|
|
2052
|
+
const feedbackFiles = [];
|
|
2053
|
+
if (slug) {
|
|
2054
|
+
const fb = loadFeedback(cfg.notesDir, slug);
|
|
2055
|
+
if (!fb) {
|
|
2056
|
+
console.log(`No feedback file for ${slug}`);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
feedbackFiles.push(fb);
|
|
2060
|
+
} else {
|
|
2061
|
+
feedbackFiles.push(...loadAllFeedback(cfg.notesDir));
|
|
2062
|
+
}
|
|
2063
|
+
const tweetIdMap = /* @__PURE__ */ new Map();
|
|
2064
|
+
for (const fb of feedbackFiles) {
|
|
2065
|
+
if (fb.tweets?.length) {
|
|
2066
|
+
for (const t of fb.tweets) {
|
|
2067
|
+
tweetIdMap.set(t.id, fb);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
if (tweetIdMap.size === 0) {
|
|
2072
|
+
console.log("No tweets found in feedback files.");
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
console.log(`Fetching metrics for ${tweetIdMap.size} tweet(s)\u2026`);
|
|
2076
|
+
let results;
|
|
2077
|
+
try {
|
|
2078
|
+
results = await fetchTweetMetrics([...tweetIdMap.keys()]);
|
|
2079
|
+
} catch (err) {
|
|
2080
|
+
console.log(`\u274C Failed to fetch metrics: ${err.message}`);
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
const updatedSlugs = /* @__PURE__ */ new Set();
|
|
2084
|
+
const rows = [];
|
|
2085
|
+
for (const r of results) {
|
|
2086
|
+
const fb = tweetIdMap.get(r.id);
|
|
2087
|
+
if (!fb) continue;
|
|
2088
|
+
const score = r.metrics.likes > 100 ? 9 : r.metrics.likes > 50 ? 8 : r.metrics.likes > 10 ? 7 : r.metrics.likes > 5 ? 6 : 5;
|
|
2089
|
+
const existing = fb.entries.find((e) => e.platform === "twitter" && e.note?.includes(r.id));
|
|
2090
|
+
if (existing) {
|
|
2091
|
+
existing.metrics = r.metrics;
|
|
2092
|
+
existing.score = score;
|
|
2093
|
+
existing.date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2094
|
+
} else {
|
|
2095
|
+
fb.entries.push({
|
|
2096
|
+
date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2097
|
+
score,
|
|
2098
|
+
metrics: r.metrics,
|
|
2099
|
+
platform: "twitter",
|
|
2100
|
+
note: `Auto-tracked engagement for tweet ${r.id}`
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
fb.aggregate = recalcAggregate(fb.entries);
|
|
2104
|
+
updatedSlugs.add(fb.slug);
|
|
2105
|
+
const prev = rows.find((row) => row.slug === fb.slug);
|
|
2106
|
+
if (prev) {
|
|
2107
|
+
prev.views += r.metrics.views;
|
|
2108
|
+
prev.likes += r.metrics.likes;
|
|
2109
|
+
prev.shares += r.metrics.shares;
|
|
2110
|
+
prev.comments += r.metrics.comments;
|
|
2111
|
+
} else {
|
|
2112
|
+
rows.push({ slug: fb.slug, ...r.metrics });
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
for (const fb of feedbackFiles) {
|
|
2116
|
+
if (updatedSlugs.has(fb.slug)) {
|
|
2117
|
+
saveFeedback(cfg.notesDir, fb);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
console.log("\n\u{1F4CA} Engagement Update\n");
|
|
2121
|
+
const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
|
|
2122
|
+
console.log(` ${pad("slug", 30)} ${pad("views", 8)} ${pad("likes", 8)} ${pad("RTs", 8)} replies`);
|
|
2123
|
+
for (const row of rows) {
|
|
2124
|
+
console.log(` ${pad(row.slug, 30)} ${pad(String(row.views), 8)} ${pad(String(row.likes), 8)} ${pad(String(row.shares), 8)} ${row.comments}`);
|
|
2125
|
+
}
|
|
2126
|
+
console.log(`
|
|
2127
|
+
Updated ${updatedSlugs.size} feedback file(s).`);
|
|
2128
|
+
});
|
|
2129
|
+
const schedule = notes.command("schedule");
|
|
2130
|
+
schedule.command("add <slug>").option("--at <datetime>", "ISO datetime or human-readable time to post").option("--platform <platform>", "platform to post to", "twitter").action(async (slug, options) => {
|
|
2131
|
+
const cfg = resolveConfig(api);
|
|
2132
|
+
const platform = options.platform;
|
|
2133
|
+
if (platform !== "twitter" && platform !== "linkedin") {
|
|
2134
|
+
console.log(`\u274C Unsupported platform "${platform}". Use: twitter, linkedin`);
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
if (!options.at) {
|
|
2138
|
+
console.log("\u274C Missing --at <datetime>. Example: --at '2026-02-08T14:00:00Z'");
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
const scheduledAt = new Date(options.at);
|
|
2142
|
+
if (isNaN(scheduledAt.getTime())) {
|
|
2143
|
+
console.log(`\u274C Invalid datetime: "${options.at}". Use ISO format, e.g. 2026-02-08T14:00:00Z`);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (scheduledAt.getTime() <= Date.now()) {
|
|
2147
|
+
console.log(`\u274C Scheduled time must be in the future. Got: ${scheduledAt.toISOString()}`);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
const platformFile = platform === "twitter" ? "twitter.md" : "linkedin.md";
|
|
2151
|
+
const adaptPath = path.join(cfg.notesDir, ".variations", slug, platformFile);
|
|
2152
|
+
if (!fs.existsSync(adaptPath)) {
|
|
2153
|
+
console.log(`\u274C No ${platform} adaptation found for "${slug}".`);
|
|
2154
|
+
console.log(`Run: openclaw notes adapt ${slug} --platform ${platform}`);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const text = fs.readFileSync(adaptPath, "utf8").trim();
|
|
2158
|
+
if (platform === "twitter" && text.length > 280) {
|
|
2159
|
+
console.log(`\u26A0\uFE0F Tweet is ${text.length} chars (max 280). Edit ${adaptPath} to shorten.`);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
const queue = loadQueue(cfg.notesDir);
|
|
2163
|
+
const existing = queue.find(
|
|
2164
|
+
(e) => e.slug === slug && e.platform === platform && e.status === "queued"
|
|
2165
|
+
);
|
|
2166
|
+
if (existing) {
|
|
2167
|
+
console.log(`\u26A0\uFE0F "${slug}" is already queued for ${platform} at ${existing.scheduledAt}.`);
|
|
2168
|
+
console.log(`Cancel it first with: openclaw notes schedule cancel ${slug}`);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
const entry = {
|
|
2172
|
+
slug,
|
|
2173
|
+
platform,
|
|
2174
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
2175
|
+
text,
|
|
2176
|
+
status: "queued",
|
|
2177
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2178
|
+
};
|
|
2179
|
+
queue.push(entry);
|
|
2180
|
+
saveQueue(cfg.notesDir, queue);
|
|
2181
|
+
console.log(`
|
|
2182
|
+
\u{1F4C5} Scheduled "${slug}" for ${platform}`);
|
|
2183
|
+
console.log(` Time: ${scheduledAt.toISOString()}`);
|
|
2184
|
+
console.log(` Text: ${text.length <= 80 ? text : text.slice(0, 77) + "..."}`);
|
|
2185
|
+
console.log(` Length: ${text.length} chars`);
|
|
2186
|
+
console.log(`
|
|
2187
|
+
Run \`openclaw notes schedule process\` at the scheduled time to post.`);
|
|
2188
|
+
});
|
|
2189
|
+
schedule.command("list").alias("ls").action(() => {
|
|
2190
|
+
const cfg = resolveConfig(api);
|
|
2191
|
+
const queue = loadQueue(cfg.notesDir);
|
|
2192
|
+
if (queue.length === 0) {
|
|
2193
|
+
console.log("\u{1F4ED} No scheduled posts.");
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
const statusOrder = { queued: 0, posted: 1, failed: 2, cancelled: 3 };
|
|
2197
|
+
const sorted = [...queue].sort(
|
|
2198
|
+
(a, b) => (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4)
|
|
2199
|
+
);
|
|
2200
|
+
const statusIcon = {
|
|
2201
|
+
queued: "\u{1F7E1}",
|
|
2202
|
+
posted: "\u{1F7E2}",
|
|
2203
|
+
failed: "\u{1F534}",
|
|
2204
|
+
cancelled: "\u26AA"
|
|
2205
|
+
};
|
|
2206
|
+
console.log(`
|
|
2207
|
+
\u{1F4C5} Scheduled Posts (${queue.length} total)
|
|
2208
|
+
`);
|
|
2209
|
+
let lastStatus = "";
|
|
2210
|
+
for (const entry of sorted) {
|
|
2211
|
+
if (entry.status !== lastStatus) {
|
|
2212
|
+
if (lastStatus) console.log("");
|
|
2213
|
+
console.log(` ${statusIcon[entry.status] || "\u26AA"} ${entry.status.toUpperCase()}`);
|
|
2214
|
+
lastStatus = entry.status;
|
|
2215
|
+
}
|
|
2216
|
+
const time = new Date(entry.scheduledAt).toLocaleString();
|
|
2217
|
+
const preview = entry.text.length <= 50 ? entry.text : entry.text.slice(0, 47) + "...";
|
|
2218
|
+
console.log(` ${entry.slug} (${entry.platform}) \u2014 ${time}`);
|
|
2219
|
+
console.log(` "${preview}"`);
|
|
2220
|
+
if (entry.tweetId) {
|
|
2221
|
+
console.log(` \u2192 https://x.com/pressclawai/status/${entry.tweetId}`);
|
|
2222
|
+
}
|
|
2223
|
+
if (entry.error) {
|
|
2224
|
+
console.log(` \u2717 ${entry.error}`);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
console.log("");
|
|
2228
|
+
});
|
|
2229
|
+
schedule.command("cancel <slug>").option("--platform <platform>", "platform to cancel", "twitter").action((slug, options) => {
|
|
2230
|
+
const cfg = resolveConfig(api);
|
|
2231
|
+
const queue = loadQueue(cfg.notesDir);
|
|
2232
|
+
const platform = options.platform || "twitter";
|
|
2233
|
+
const idx = queue.findIndex(
|
|
2234
|
+
(e) => e.slug === slug && e.platform === platform && e.status === "queued"
|
|
2235
|
+
);
|
|
2236
|
+
if (idx === -1) {
|
|
2237
|
+
console.log(`\u274C No queued entry found for "${slug}" on ${platform}.`);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
queue[idx].status = "cancelled";
|
|
2241
|
+
saveQueue(cfg.notesDir, queue);
|
|
2242
|
+
console.log(`\u2705 Cancelled scheduled post for "${slug}" (${platform}).`);
|
|
2243
|
+
console.log(` Was scheduled for: ${queue[idx].scheduledAt}`);
|
|
2244
|
+
});
|
|
2245
|
+
schedule.command("process").action(async () => {
|
|
2246
|
+
const cfg = resolveConfig(api);
|
|
2247
|
+
const queue = loadQueue(cfg.notesDir);
|
|
2248
|
+
const now = Date.now();
|
|
2249
|
+
const due = queue.filter(
|
|
2250
|
+
(e) => e.status === "queued" && new Date(e.scheduledAt).getTime() <= now
|
|
2251
|
+
);
|
|
2252
|
+
if (due.length === 0) {
|
|
2253
|
+
const nextQueued = queue.filter((e) => e.status === "queued").sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
|
|
2254
|
+
if (nextQueued.length > 0) {
|
|
2255
|
+
const next = nextQueued[0];
|
|
2256
|
+
const diff = new Date(next.scheduledAt).getTime() - now;
|
|
2257
|
+
const mins = Math.ceil(diff / 6e4);
|
|
2258
|
+
console.log(`\u23F3 No posts due yet. Next: "${next.slug}" in ${mins} minute(s) (${next.scheduledAt}).`);
|
|
2259
|
+
} else {
|
|
2260
|
+
console.log("\u{1F4ED} No queued posts to process.");
|
|
2261
|
+
}
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
console.log(`
|
|
2265
|
+
\u{1F4E4} Processing ${due.length} scheduled post(s)...
|
|
2266
|
+
`);
|
|
2267
|
+
let posted = 0;
|
|
2268
|
+
let failed = 0;
|
|
2269
|
+
for (const entry of due) {
|
|
2270
|
+
process.stdout.write(` ${entry.slug} (${entry.platform})... `);
|
|
2271
|
+
if (entry.platform === "twitter") {
|
|
2272
|
+
try {
|
|
2273
|
+
const result = await postTweet(entry.text);
|
|
2274
|
+
entry.status = "posted";
|
|
2275
|
+
entry.tweetId = result.id;
|
|
2276
|
+
let fb = loadFeedback(cfg.notesDir, entry.slug);
|
|
2277
|
+
if (!fb) {
|
|
2278
|
+
fb = {
|
|
2279
|
+
slug: entry.slug,
|
|
2280
|
+
entries: [],
|
|
2281
|
+
tweets: [],
|
|
2282
|
+
aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 }
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
if (!fb.tweets) fb.tweets = [];
|
|
2286
|
+
fb.tweets.push({
|
|
2287
|
+
id: result.id,
|
|
2288
|
+
text: result.text,
|
|
2289
|
+
postedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2290
|
+
platform: "twitter"
|
|
2291
|
+
});
|
|
2292
|
+
saveFeedback(cfg.notesDir, fb);
|
|
2293
|
+
console.log(`\u2705 Posted! https://x.com/pressclawai/status/${result.id}`);
|
|
2294
|
+
posted++;
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
entry.status = "failed";
|
|
2297
|
+
entry.error = err.message || String(err);
|
|
2298
|
+
console.log(`\u274C Failed: ${entry.error}`);
|
|
2299
|
+
failed++;
|
|
2300
|
+
}
|
|
2301
|
+
} else if (entry.platform === "linkedin") {
|
|
2302
|
+
try {
|
|
2303
|
+
const result = await postLinkedIn(entry.text);
|
|
2304
|
+
entry.status = "posted";
|
|
2305
|
+
entry.tweetId = result.id;
|
|
2306
|
+
let fb = loadFeedback(cfg.notesDir, entry.slug);
|
|
2307
|
+
if (!fb) {
|
|
2308
|
+
fb = {
|
|
2309
|
+
slug: entry.slug,
|
|
2310
|
+
entries: [],
|
|
2311
|
+
tweets: [],
|
|
2312
|
+
aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 }
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
if (!fb.tweets) fb.tweets = [];
|
|
2316
|
+
fb.tweets.push({
|
|
2317
|
+
id: result.id,
|
|
2318
|
+
text: entry.text.slice(0, 200) + (entry.text.length > 200 ? "\u2026" : ""),
|
|
2319
|
+
postedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2320
|
+
platform: "linkedin"
|
|
2321
|
+
});
|
|
2322
|
+
saveFeedback(cfg.notesDir, fb);
|
|
2323
|
+
console.log(`\u2705 Posted to LinkedIn! ${result.id}`);
|
|
2324
|
+
posted++;
|
|
2325
|
+
} catch (err) {
|
|
2326
|
+
entry.status = "failed";
|
|
2327
|
+
entry.error = err.message || String(err);
|
|
2328
|
+
console.log(`\u274C Failed: ${entry.error}`);
|
|
2329
|
+
failed++;
|
|
2330
|
+
}
|
|
2331
|
+
} else {
|
|
2332
|
+
entry.status = "failed";
|
|
2333
|
+
entry.error = `Unsupported platform: ${entry.platform}`;
|
|
2334
|
+
console.log(`\u274C ${entry.error}`);
|
|
2335
|
+
failed++;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
saveQueue(cfg.notesDir, queue);
|
|
2339
|
+
console.log(`
|
|
2340
|
+
\u{1F4CA} Results: ${posted} posted, ${failed} failed`);
|
|
2341
|
+
if (posted > 0) {
|
|
2342
|
+
console.log(`\u{1F4CE} Post IDs saved to .feedback/ files.`);
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
schedule.action(() => {
|
|
2346
|
+
schedule.commands.find((c) => c.name() === "list")?.parse(["list"], { from: "user" });
|
|
2347
|
+
});
|
|
2348
|
+
notes.command("linkedin <slug>").option("--dry-run", "preview only, don't post").option("--yes", "skip confirmation").action(async (slug, options) => {
|
|
2349
|
+
const cfg = resolveConfig(api);
|
|
2350
|
+
const linkedinPath = path.join(cfg.notesDir, ".variations", slug, "linkedin.md");
|
|
2351
|
+
if (!fs.existsSync(linkedinPath)) {
|
|
2352
|
+
console.log(`No LinkedIn adaptation found for "${slug}".`);
|
|
2353
|
+
console.log(`Run: openclaw notes adapt ${slug} --platform linkedin`);
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
const postText = fs.readFileSync(linkedinPath, "utf8").trim();
|
|
2357
|
+
if (postText.length > 3e3) {
|
|
2358
|
+
console.log(`\u26A0\uFE0F Post is ${postText.length} chars (LinkedIn max ~3000). Edit ${linkedinPath} to shorten.`);
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
console.log(`
|
|
2362
|
+
\u{1F4BC} LinkedIn post preview (${postText.length}/3000 chars):
|
|
2363
|
+
`);
|
|
2364
|
+
console.log(` ${postText.split("\n").join("\n ")}
|
|
2365
|
+
`);
|
|
2366
|
+
if (options.dryRun) {
|
|
2367
|
+
console.log("(dry run \u2014 not posting)");
|
|
1156
2368
|
return;
|
|
1157
2369
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
2370
|
+
if (!options.yes) {
|
|
2371
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2372
|
+
const answer = await rl.question("Post to LinkedIn? (y/n) ");
|
|
2373
|
+
rl.close();
|
|
2374
|
+
if (answer.toLowerCase() !== "y") {
|
|
2375
|
+
console.log("Cancelled.");
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
try {
|
|
2380
|
+
const result = await postLinkedIn(postText);
|
|
2381
|
+
console.log(`\u2705 Posted to LinkedIn!`);
|
|
2382
|
+
if (result.id) {
|
|
2383
|
+
const numericId = result.id.split(":").pop();
|
|
2384
|
+
console.log(` Post URN: ${result.id}`);
|
|
2385
|
+
console.log(` View: https://www.linkedin.com/feed/update/${result.id}`);
|
|
2386
|
+
}
|
|
2387
|
+
let fb = loadFeedback(cfg.notesDir, slug);
|
|
2388
|
+
if (!fb) {
|
|
2389
|
+
fb = {
|
|
2390
|
+
slug,
|
|
2391
|
+
entries: [],
|
|
2392
|
+
tweets: [],
|
|
2393
|
+
aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 }
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
if (!fb.tweets) fb.tweets = [];
|
|
2397
|
+
fb.tweets.push({
|
|
2398
|
+
id: result.id,
|
|
2399
|
+
text: postText.slice(0, 200) + (postText.length > 200 ? "\u2026" : ""),
|
|
2400
|
+
postedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2401
|
+
platform: "linkedin"
|
|
2402
|
+
});
|
|
2403
|
+
saveFeedback(cfg.notesDir, fb);
|
|
2404
|
+
console.log(`\u{1F4CE} LinkedIn post ID saved to .feedback/${slug}.json`);
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
console.log(`\u274C Failed to post: ${err.message}`);
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
notes.command("linkedin-auth").action(async () => {
|
|
2410
|
+
console.log(`
|
|
2411
|
+
\u{1F517} LinkedIn API Setup`);
|
|
1161
2412
|
console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
|
|
1162
|
-
console.log(`Platform: ${spec.name} (${spec.format})`);
|
|
1163
|
-
console.log(`Max chars: ${spec.maxChars}`);
|
|
1164
|
-
console.log(`Source: ${wordCount} words
|
|
1165
|
-
`);
|
|
1166
|
-
console.log(`Original blog post:`);
|
|
1167
|
-
console.log(body);
|
|
1168
2413
|
console.log();
|
|
1169
|
-
console.log(
|
|
1170
|
-
console.log(`
|
|
1171
|
-
`);
|
|
1172
|
-
console.log(
|
|
1173
|
-
|
|
2414
|
+
console.log(`Step 1: Create a LinkedIn App`);
|
|
2415
|
+
console.log(` \u2192 Go to https://www.linkedin.com/developers/apps`);
|
|
2416
|
+
console.log(` \u2192 Click "Create app"`);
|
|
2417
|
+
console.log(` \u2192 Fill in: App name, LinkedIn Page (or create one), logo`);
|
|
2418
|
+
console.log(` \u2192 Accept the terms and create`);
|
|
1174
2419
|
console.log();
|
|
1175
|
-
console.log(
|
|
2420
|
+
console.log(`Step 2: Configure OAuth`);
|
|
2421
|
+
console.log(` \u2192 In your app settings, go to the "Auth" tab`);
|
|
2422
|
+
console.log(` \u2192 Note your Client ID and Client Secret`);
|
|
2423
|
+
console.log(` \u2192 Under "OAuth 2.0 settings", add a redirect URL:`);
|
|
2424
|
+
console.log(` https://localhost:3000/callback (or any URL you control)`);
|
|
1176
2425
|
console.log();
|
|
1177
|
-
console.log(
|
|
1178
|
-
console.log(`
|
|
2426
|
+
console.log(`Step 3: Request the w_member_social product`);
|
|
2427
|
+
console.log(` \u2192 Go to the "Products" tab in your LinkedIn app`);
|
|
2428
|
+
console.log(` \u2192 Request access to "Share on LinkedIn" (grants w_member_social)`);
|
|
2429
|
+
console.log(` \u2192 Wait for approval (usually instant for personal apps)`);
|
|
1179
2430
|
console.log();
|
|
1180
|
-
|
|
1181
|
-
|
|
2431
|
+
console.log(`Step 4: Get an authorization code`);
|
|
2432
|
+
console.log(` \u2192 Visit this URL in your browser (replace YOUR_CLIENT_ID and YOUR_REDIRECT_URI):
|
|
2433
|
+
`);
|
|
2434
|
+
console.log(` https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&scope=openid%20profile%20w_member_social
|
|
2435
|
+
`);
|
|
2436
|
+
console.log(` \u2192 Log in and authorize the app`);
|
|
2437
|
+
console.log(` \u2192 You'll be redirected to your redirect URL with ?code=XXXXX in the URL`);
|
|
2438
|
+
console.log(` \u2192 Copy the code value`);
|
|
2439
|
+
console.log();
|
|
2440
|
+
console.log(`Step 5: Enter your credentials below to exchange for an access token
|
|
2441
|
+
`);
|
|
2442
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2443
|
+
try {
|
|
2444
|
+
const clientId = await rl.question("LinkedIn Client ID: ");
|
|
2445
|
+
if (!clientId.trim()) {
|
|
2446
|
+
console.log("Cancelled.");
|
|
2447
|
+
rl.close();
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
const clientSecret = await rl.question("LinkedIn Client Secret: ");
|
|
2451
|
+
if (!clientSecret.trim()) {
|
|
2452
|
+
console.log("Cancelled.");
|
|
2453
|
+
rl.close();
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
const redirectUri = await rl.question("Redirect URI (same as in your app settings): ");
|
|
2457
|
+
if (!redirectUri.trim()) {
|
|
2458
|
+
console.log("Cancelled.");
|
|
2459
|
+
rl.close();
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
const code = await rl.question("Authorization code (from the redirect URL): ");
|
|
2463
|
+
if (!code.trim()) {
|
|
2464
|
+
console.log("Cancelled.");
|
|
2465
|
+
rl.close();
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
rl.close();
|
|
2469
|
+
console.log(`
|
|
2470
|
+
\u23F3 Exchanging authorization code for access token...`);
|
|
2471
|
+
const tokenResult = await exchangeLinkedInAuthCode(
|
|
2472
|
+
clientId.trim(),
|
|
2473
|
+
clientSecret.trim(),
|
|
2474
|
+
code.trim(),
|
|
2475
|
+
redirectUri.trim()
|
|
2476
|
+
);
|
|
2477
|
+
console.log(`\u2705 Access token obtained! (expires in ${Math.round(tokenResult.expires_in / 86400)} days)`);
|
|
2478
|
+
console.log(` Scopes: ${tokenResult.scope}`);
|
|
2479
|
+
console.log(`
|
|
2480
|
+
\u23F3 Fetching your LinkedIn profile...`);
|
|
2481
|
+
let personUrn;
|
|
2482
|
+
try {
|
|
2483
|
+
personUrn = await fetchLinkedInPersonUrn(tokenResult.access_token);
|
|
2484
|
+
console.log(`\u2705 Person URN: ${personUrn}`);
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
console.log(`\u26A0\uFE0F Could not auto-detect Person URN: ${err.message}`);
|
|
2487
|
+
const manualUrn = await readline.createInterface({ input: process.stdin, output: process.stdout }).question("Enter your Person URN manually (urn:li:person:XXXX): ");
|
|
2488
|
+
personUrn = manualUrn.trim();
|
|
2489
|
+
if (!personUrn) {
|
|
2490
|
+
console.log("No Person URN provided. You'll need to set LINKEDIN_PERSON_URN manually.");
|
|
2491
|
+
personUrn = "";
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
const envDir = "/etc/openclaw";
|
|
2495
|
+
const envPath = path.join(envDir, "linkedin.env");
|
|
2496
|
+
fs.mkdirSync(envDir, { recursive: true });
|
|
2497
|
+
const envContent = [
|
|
2498
|
+
`# LinkedIn OAuth credentials for PressClaw`,
|
|
2499
|
+
`# Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
2500
|
+
`# Token expires in ~${Math.round(tokenResult.expires_in / 86400)} days`,
|
|
2501
|
+
`LINKEDIN_ACCESS_TOKEN=${tokenResult.access_token}`,
|
|
2502
|
+
personUrn ? `LINKEDIN_PERSON_URN=${personUrn}` : `# LINKEDIN_PERSON_URN=urn:li:person:YOUR_ID`,
|
|
2503
|
+
`LINKEDIN_CLIENT_ID=${clientId.trim()}`,
|
|
2504
|
+
`LINKEDIN_CLIENT_SECRET=${clientSecret.trim()}`,
|
|
2505
|
+
`LINKEDIN_REDIRECT_URI=${redirectUri.trim()}`,
|
|
2506
|
+
``
|
|
2507
|
+
].join("\n");
|
|
2508
|
+
fs.writeFileSync(envPath, envContent, { mode: 384 });
|
|
2509
|
+
console.log(`
|
|
2510
|
+
\u{1F4BE} Credentials saved to ${envPath}`);
|
|
1182
2511
|
console.log();
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
console.log(`\u{1F3AD} VOICE (${namedPersona.name}):`);
|
|
1186
|
-
console.log(` ${namedPersona.voiceDescription}`);
|
|
1187
|
-
if (namedPersona.avoid?.length) console.log(` Avoid: ${namedPersona.avoid.slice(0, 5).join(", ")}`);
|
|
2512
|
+
console.log(`To load these in your OpenClaw environment, add to your gateway config:`);
|
|
2513
|
+
console.log(` envFile: ${envPath}`);
|
|
1188
2514
|
console.log();
|
|
1189
|
-
|
|
1190
|
-
console.log(
|
|
1191
|
-
console.log(` ${styleProfile.voiceDescription}`);
|
|
2515
|
+
console.log(`Or source it manually:`);
|
|
2516
|
+
console.log(` source ${envPath}`);
|
|
1192
2517
|
console.log();
|
|
2518
|
+
console.log(`You can now post to LinkedIn:`);
|
|
2519
|
+
console.log(` openclaw notes adapt <slug> --platform linkedin`);
|
|
2520
|
+
console.log(` openclaw notes linkedin <slug>`);
|
|
2521
|
+
} catch (err) {
|
|
2522
|
+
rl.close();
|
|
2523
|
+
console.log(`
|
|
2524
|
+
\u274C Error: ${err.message}`);
|
|
1193
2525
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
console.log(
|
|
2526
|
+
});
|
|
2527
|
+
const personasCmd = notes.command("personas").description("Manage writing personas");
|
|
2528
|
+
personasCmd.action(() => {
|
|
2529
|
+
const cfg = resolveConfig(api);
|
|
2530
|
+
const personas = loadInstalledPersonas(cfg.notesDir);
|
|
2531
|
+
if (personas.length === 0) {
|
|
2532
|
+
console.log("\n\u{1F3AD} No personas installed yet.");
|
|
2533
|
+
console.log(" Browse available: openclaw notes personas browse");
|
|
2534
|
+
console.log(" Create your own: openclaw notes personas create");
|
|
2535
|
+
return;
|
|
1201
2536
|
}
|
|
1202
|
-
console.log(
|
|
1203
|
-
|
|
1204
|
-
console.log(
|
|
2537
|
+
console.log(`
|
|
2538
|
+
\u{1F3AD} Installed Personas (${personas.length})`);
|
|
2539
|
+
console.log(`${"\u2501".repeat(72)}`);
|
|
2540
|
+
console.log(` ${"Name".padEnd(20)} ${"Description".padEnd(32)} ${"Tags".padEnd(24)} Ver`);
|
|
2541
|
+
console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(32)} ${"\u2500".repeat(24)} ${"\u2500".repeat(4)}`);
|
|
2542
|
+
for (const p of personas) {
|
|
2543
|
+
const name = p.displayName.slice(0, 19).padEnd(20);
|
|
2544
|
+
const desc = p.description.slice(0, 31).padEnd(32);
|
|
2545
|
+
const tags = p.tags.slice(0, 3).join(", ").slice(0, 23).padEnd(24);
|
|
2546
|
+
const ver = p.version || "\u2014";
|
|
2547
|
+
console.log(` ${name} ${desc} ${tags} ${ver}`);
|
|
2548
|
+
}
|
|
2549
|
+
console.log(`
|
|
2550
|
+
Browse more: openclaw notes personas browse`);
|
|
2551
|
+
console.log(` Use with: openclaw notes transform <slug> --voice <persona-name>`);
|
|
2552
|
+
});
|
|
2553
|
+
personasCmd.command("browse").action(async () => {
|
|
2554
|
+
const cfg = resolveConfig(api);
|
|
2555
|
+
const installed = new Set(loadInstalledPersonas(cfg.notesDir).map((p) => p.name));
|
|
2556
|
+
console.log("\n\u{1F310} Persona Marketplace");
|
|
2557
|
+
console.log(`${"\u2501".repeat(56)}`);
|
|
2558
|
+
console.log(" Fetching from pressclaw.com...\n");
|
|
2559
|
+
try {
|
|
2560
|
+
const index = await fetchJson("https://pressclaw.com/personas/index.json");
|
|
2561
|
+
if (!index?.personas || !Array.isArray(index.personas)) {
|
|
2562
|
+
console.log(" \u26A0\uFE0F Invalid marketplace index format.");
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
const available = index.personas.filter((p) => !installed.has(p.name));
|
|
2566
|
+
if (available.length === 0 && index.personas.length > 0) {
|
|
2567
|
+
console.log(" \u2705 You already have all available personas installed!");
|
|
2568
|
+
console.log(` ${index.personas.length} persona(s) in marketplace, all installed locally.`);
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
if (available.length === 0) {
|
|
2572
|
+
console.log(" No personas available in the marketplace yet.");
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
console.log(` ${available.length} persona(s) available:
|
|
2576
|
+
`);
|
|
2577
|
+
for (const p of available) {
|
|
2578
|
+
const tags = p.tags?.length ? ` [${p.tags.join(", ")}]` : "";
|
|
2579
|
+
console.log(` \u{1F3AD} ${p.displayName || p.name} (${p.name}) v${p.version || "?"}`);
|
|
2580
|
+
console.log(` ${p.description || "No description"}${tags}`);
|
|
2581
|
+
console.log();
|
|
2582
|
+
}
|
|
2583
|
+
if (installed.size > 0) {
|
|
2584
|
+
console.log(` Already installed: ${[...installed].join(", ")}`);
|
|
2585
|
+
console.log();
|
|
2586
|
+
}
|
|
2587
|
+
console.log(` Install: openclaw notes personas install <name>`);
|
|
2588
|
+
} catch (err) {
|
|
2589
|
+
console.log(" \u{1F310} Marketplace is not available yet.");
|
|
2590
|
+
console.log(` (${err?.message || "Connection failed"})`);
|
|
2591
|
+
console.log();
|
|
2592
|
+
console.log(" You can still create personas locally:");
|
|
2593
|
+
console.log(" openclaw notes personas create");
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
personasCmd.command("install <name>").action(async (name) => {
|
|
2597
|
+
const cfg = resolveConfig(api);
|
|
2598
|
+
ensurePersonasDir(cfg.notesDir);
|
|
2599
|
+
const existing = loadInstalledPersona(cfg.notesDir, name);
|
|
2600
|
+
if (existing) {
|
|
2601
|
+
console.log(`
|
|
2602
|
+
\u26A0\uFE0F Persona "${existing.displayName}" (${name}) is already installed.`);
|
|
2603
|
+
console.log(` Version: ${existing.version}`);
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
console.log(`
|
|
2607
|
+
\u{1F4E5} Installing persona: ${name}...`);
|
|
2608
|
+
try {
|
|
2609
|
+
const data = await fetchJson(`https://pressclaw.com/personas/${name}.json`);
|
|
2610
|
+
if (!validatePersona(data)) {
|
|
2611
|
+
console.log(`
|
|
2612
|
+
\u274C Invalid persona format \u2014 missing required fields.`);
|
|
2613
|
+
console.log(` Required: name, displayName, description, version, author, style.tone, style.patterns, examples, tags`);
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
const dest = path.join(personasDir(cfg.notesDir), `${name}.json`);
|
|
2617
|
+
fs.writeFileSync(dest, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
2618
|
+
console.log(`
|
|
2619
|
+
\u2705 Installed: ${data.displayName}`);
|
|
2620
|
+
console.log(` ${data.description}`);
|
|
2621
|
+
console.log(` Tone: ${data.style.tone} \xB7 ${data.style.patterns.length} patterns \xB7 ${data.examples.length} examples`);
|
|
2622
|
+
console.log(` Tags: ${data.tags.join(", ")}`);
|
|
2623
|
+
console.log(`
|
|
2624
|
+
Use: openclaw notes transform <slug> --voice ${name}`);
|
|
2625
|
+
} catch (err) {
|
|
2626
|
+
console.log(`
|
|
2627
|
+
\u274C Failed to install persona "${name}".`);
|
|
2628
|
+
console.log(` ${err?.message || "Download failed"}`);
|
|
2629
|
+
console.log(`
|
|
2630
|
+
Check available personas: openclaw notes personas browse`);
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
personasCmd.command("export <name>").action((name) => {
|
|
2634
|
+
const cfg = resolveConfig(api);
|
|
2635
|
+
const persona = loadInstalledPersona(cfg.notesDir, name);
|
|
2636
|
+
if (!persona) {
|
|
2637
|
+
console.log(`
|
|
2638
|
+
\u274C Persona "${name}" not found locally.`);
|
|
2639
|
+
console.log(` Installed personas: openclaw notes personas`);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
console.log(JSON.stringify(persona, null, 2));
|
|
2643
|
+
});
|
|
2644
|
+
personasCmd.command("create").action(async () => {
|
|
2645
|
+
const cfg = resolveConfig(api);
|
|
2646
|
+
ensurePersonasDir(cfg.notesDir);
|
|
2647
|
+
if (!process.stdin.isTTY) {
|
|
2648
|
+
console.log("\u274C Interactive mode required. Run in a terminal.");
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2652
|
+
try {
|
|
2653
|
+
console.log("\n\u{1F3AD} Create a New Persona");
|
|
2654
|
+
console.log(`${"\u2501".repeat(40)}
|
|
2655
|
+
`);
|
|
2656
|
+
const rawName = await rl.question("Name (slug, e.g. 'my-voice'): ");
|
|
2657
|
+
const name = slugifyPersonaName(rawName);
|
|
2658
|
+
if (!name) {
|
|
2659
|
+
console.log("\u274C Invalid name.");
|
|
2660
|
+
rl.close();
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (loadInstalledPersona(cfg.notesDir, name)) {
|
|
2664
|
+
console.log(`\u274C Persona "${name}" already exists. Delete it first or choose another name.`);
|
|
2665
|
+
rl.close();
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
const displayName = await rl.question("Display name (e.g. 'Paul Graham'): ");
|
|
2669
|
+
if (!displayName.trim()) {
|
|
2670
|
+
console.log("\u274C Display name is required.");
|
|
2671
|
+
rl.close();
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
const description = await rl.question("Description (one line): ");
|
|
2675
|
+
const tone = await rl.question("Tone (e.g. analytical, conversational, provocative): ");
|
|
2676
|
+
console.log("\nEnter 3 writing patterns (one per line):");
|
|
2677
|
+
const pattern1 = await rl.question(" Pattern 1: ");
|
|
2678
|
+
const pattern2 = await rl.question(" Pattern 2: ");
|
|
2679
|
+
const pattern3 = await rl.question(" Pattern 3: ");
|
|
2680
|
+
const patterns = [pattern1, pattern2, pattern3].filter((p) => p.trim());
|
|
2681
|
+
if (patterns.length === 0) {
|
|
2682
|
+
console.log("\u274C At least one writing pattern is required.");
|
|
2683
|
+
rl.close();
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
const tagsRaw = await rl.question("Tags (comma-separated, e.g. tech, essays): ");
|
|
2687
|
+
const tags = tagsRaw.split(",").map((t) => t.trim()).filter((t) => t);
|
|
2688
|
+
rl.close();
|
|
2689
|
+
const persona = {
|
|
2690
|
+
name,
|
|
2691
|
+
displayName: displayName.trim(),
|
|
2692
|
+
description: description.trim() || `Custom persona: ${displayName.trim()}`,
|
|
2693
|
+
version: "1.0",
|
|
2694
|
+
author: "local",
|
|
2695
|
+
style: {
|
|
2696
|
+
tone: tone.trim() || "custom",
|
|
2697
|
+
patterns
|
|
2698
|
+
},
|
|
2699
|
+
examples: [],
|
|
2700
|
+
tags
|
|
2701
|
+
};
|
|
2702
|
+
const dest = path.join(personasDir(cfg.notesDir), `${name}.json`);
|
|
2703
|
+
fs.writeFileSync(dest, JSON.stringify(persona, null, 2) + "\n", "utf8");
|
|
2704
|
+
console.log(`
|
|
2705
|
+
\u2705 Created persona: ${persona.displayName}`);
|
|
2706
|
+
console.log(` Saved to: ${dest}`);
|
|
2707
|
+
console.log(` Use: openclaw notes transform <slug> --voice ${name}`);
|
|
2708
|
+
console.log(` Export: openclaw notes personas export ${name}`);
|
|
2709
|
+
} catch (err) {
|
|
2710
|
+
rl.close();
|
|
2711
|
+
console.log(`
|
|
2712
|
+
\u274C Error creating persona: ${err?.message || "unknown"}`);
|
|
2713
|
+
}
|
|
2714
|
+
});
|
|
2715
|
+
personasCmd.command("delete <name>").action(async (name) => {
|
|
2716
|
+
const cfg = resolveConfig(api);
|
|
2717
|
+
const persona = loadInstalledPersona(cfg.notesDir, name);
|
|
2718
|
+
if (!persona) {
|
|
2719
|
+
console.log(`
|
|
2720
|
+
\u274C Persona "${name}" not found locally.`);
|
|
2721
|
+
console.log(` Installed personas: openclaw notes personas`);
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
if (process.stdin.isTTY) {
|
|
2725
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2726
|
+
const answer = await rl.question(`Delete persona "${persona.displayName}" (${name})? (y/N) `);
|
|
2727
|
+
rl.close();
|
|
2728
|
+
if (!(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes")) {
|
|
2729
|
+
console.log("Cancelled.");
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
const file = path.join(personasDir(cfg.notesDir), `${name}.json`);
|
|
2734
|
+
fs.unlinkSync(file);
|
|
2735
|
+
console.log(`
|
|
2736
|
+
\u{1F5D1}\uFE0F Deleted persona: ${persona.displayName} (${name})`);
|
|
1205
2737
|
});
|
|
1206
2738
|
notes.command("analyze <slug>").action((slug) => {
|
|
1207
2739
|
const cfg = resolveConfig(api);
|
|
@@ -1617,6 +3149,343 @@ ${varContent}
|
|
|
1617
3149
|
console.log(`
|
|
1618
3150
|
Next: openclaw notes refine ${slug}`);
|
|
1619
3151
|
});
|
|
3152
|
+
notes.command("feedback <slug>").description("Record performance feedback for a note").option("--score <n>", "overall quality rating (1-10)", parseInt).option("--views <n>", "view count", parseInt).option("--likes <n>", "likes/hearts", parseInt).option("--shares <n>", "shares/reposts", parseInt).option("--comments <n>", "comment count", parseInt).option("--platform <name>", "platform name (blog, linkedin, twitter)").option("--note <text>", "free-text note about what worked/didn't").action((slug, options) => {
|
|
3153
|
+
const cfg = resolveConfig(api);
|
|
3154
|
+
const noteFile = path.join(cfg.notesDir, `${slug}.md`);
|
|
3155
|
+
if (!fs.existsSync(noteFile)) {
|
|
3156
|
+
console.log(`\u274C Note not found: ${slug}.md`);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (options.score !== void 0 && (options.score < 1 || options.score > 10 || isNaN(options.score))) {
|
|
3160
|
+
console.log("\u274C Score must be between 1 and 10");
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
if (!options.score && !options.views && !options.likes && !options.shares && !options.comments && !options.note) {
|
|
3164
|
+
console.log("\u274C Provide at least --score, a metric (--views/--likes/--shares/--comments), or --note");
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
const entry = {
|
|
3168
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3169
|
+
score: options.score || 0,
|
|
3170
|
+
metrics: {
|
|
3171
|
+
...options.views !== void 0 ? { views: options.views } : {},
|
|
3172
|
+
...options.likes !== void 0 ? { likes: options.likes } : {},
|
|
3173
|
+
...options.shares !== void 0 ? { shares: options.shares } : {},
|
|
3174
|
+
...options.comments !== void 0 ? { comments: options.comments } : {}
|
|
3175
|
+
},
|
|
3176
|
+
...options.platform ? { platform: options.platform } : {},
|
|
3177
|
+
...options.note ? { note: options.note } : {}
|
|
3178
|
+
};
|
|
3179
|
+
let fb = loadFeedback(cfg.notesDir, slug);
|
|
3180
|
+
if (!fb) {
|
|
3181
|
+
fb = { slug, entries: [], aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 } };
|
|
3182
|
+
}
|
|
3183
|
+
fb.entries.push(entry);
|
|
3184
|
+
fb.aggregate = recalcAggregate(fb.entries);
|
|
3185
|
+
saveFeedback(cfg.notesDir, fb);
|
|
3186
|
+
updatePerformanceWeights(cfg.notesDir);
|
|
3187
|
+
console.log(`
|
|
3188
|
+
\u2705 Feedback recorded for "${slug}"`);
|
|
3189
|
+
console.log(` Score: ${entry.score || "\u2014"} | Platform: ${entry.platform || "\u2014"}`);
|
|
3190
|
+
if (Object.keys(entry.metrics).length > 0) {
|
|
3191
|
+
const parts = [];
|
|
3192
|
+
if (entry.metrics.views !== void 0) parts.push(`${entry.metrics.views} views`);
|
|
3193
|
+
if (entry.metrics.likes !== void 0) parts.push(`${entry.metrics.likes} likes`);
|
|
3194
|
+
if (entry.metrics.shares !== void 0) parts.push(`${entry.metrics.shares} shares`);
|
|
3195
|
+
if (entry.metrics.comments !== void 0) parts.push(`${entry.metrics.comments} comments`);
|
|
3196
|
+
console.log(` Metrics: ${parts.join(", ")}`);
|
|
3197
|
+
}
|
|
3198
|
+
if (entry.note) console.log(` Note: ${entry.note}`);
|
|
3199
|
+
console.log(`
|
|
3200
|
+
Aggregate (${fb.aggregate.entries} entries): avg score ${fb.aggregate.avgScore} \xB7 ${fb.aggregate.totalViews} views \xB7 ${fb.aggregate.totalLikes} likes \xB7 ${fb.aggregate.totalShares} shares`);
|
|
3201
|
+
});
|
|
3202
|
+
notes.command("insights").description("Correlate performance feedback with style markers").action(() => {
|
|
3203
|
+
const cfg = resolveConfig(api);
|
|
3204
|
+
const allFeedback = loadAllFeedback(cfg.notesDir);
|
|
3205
|
+
if (allFeedback.length === 0) {
|
|
3206
|
+
console.log("No feedback data yet. Record feedback with: openclaw notes feedback <slug> --score 8 --views 100");
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
const notes2 = [];
|
|
3210
|
+
for (const fb of allFeedback) {
|
|
3211
|
+
if (fb.aggregate.entries === 0) continue;
|
|
3212
|
+
const noteFile = path.join(cfg.notesDir, `${fb.slug}.md`);
|
|
3213
|
+
if (!fs.existsSync(noteFile)) continue;
|
|
3214
|
+
const { meta, body } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
|
|
3215
|
+
let markers = null;
|
|
3216
|
+
if (meta.style_markers) {
|
|
3217
|
+
try {
|
|
3218
|
+
markers = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers;
|
|
3219
|
+
} catch {
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
if (!markers) markers = extractStyleMarkers(body);
|
|
3223
|
+
const platforms = [...new Set(fb.entries.filter((e) => e.platform).map((e) => e.platform))];
|
|
3224
|
+
notes2.push({
|
|
3225
|
+
slug: fb.slug,
|
|
3226
|
+
score: fb.aggregate.avgScore,
|
|
3227
|
+
totalViews: fb.aggregate.totalViews,
|
|
3228
|
+
totalLikes: fb.aggregate.totalLikes,
|
|
3229
|
+
totalShares: fb.aggregate.totalShares,
|
|
3230
|
+
totalComments: fb.aggregate.totalComments,
|
|
3231
|
+
entries: fb.aggregate.entries,
|
|
3232
|
+
tone: meta.tone || null,
|
|
3233
|
+
structure: meta.structure || null,
|
|
3234
|
+
markers,
|
|
3235
|
+
platforms
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
if (notes2.length === 0) {
|
|
3239
|
+
console.log("No matched feedback + notes found.");
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
console.log(`
|
|
3243
|
+
\u{1F4CA} Performance Insights (${notes2.length} notes with feedback)
|
|
3244
|
+
`);
|
|
3245
|
+
const sorted = [...notes2].sort((a, b) => b.score - a.score);
|
|
3246
|
+
console.log("\u{1F3C6} Top Performers:");
|
|
3247
|
+
for (const n of sorted.slice(0, 5)) {
|
|
3248
|
+
console.log(` ${n.score}/10 ${n.slug} (${n.totalViews} views, ${n.totalLikes} likes, ${n.totalShares} shares)`);
|
|
3249
|
+
}
|
|
3250
|
+
console.log();
|
|
3251
|
+
const withSentLen = notes2.filter((n) => n.markers?.avgSentenceLength);
|
|
3252
|
+
if (withSentLen.length >= 2) {
|
|
3253
|
+
const median = [...withSentLen].sort((a, b) => a.score - b.score);
|
|
3254
|
+
const topHalf = median.slice(Math.floor(median.length / 2));
|
|
3255
|
+
const bottomHalf = median.slice(0, Math.floor(median.length / 2));
|
|
3256
|
+
if (topHalf.length > 0 && bottomHalf.length > 0) {
|
|
3257
|
+
const topAvgSL = Math.round(topHalf.reduce((s, n) => s + n.markers.avgSentenceLength, 0) / topHalf.length * 10) / 10;
|
|
3258
|
+
const botAvgSL = Math.round(bottomHalf.reduce((s, n) => s + n.markers.avgSentenceLength, 0) / bottomHalf.length * 10) / 10;
|
|
3259
|
+
console.log(`\u{1F4CF} Sentence Length:`);
|
|
3260
|
+
console.log(` Your highest-rated posts average ${topAvgSL} words/sentence (vs ${botAvgSL} for lowest)`);
|
|
3261
|
+
console.log();
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
const structGroups = {};
|
|
3265
|
+
for (const n of notes2) {
|
|
3266
|
+
if (!n.structure) continue;
|
|
3267
|
+
if (!structGroups[n.structure]) structGroups[n.structure] = { total: 0, count: 0 };
|
|
3268
|
+
structGroups[n.structure].total += n.score;
|
|
3269
|
+
structGroups[n.structure].count += 1;
|
|
3270
|
+
}
|
|
3271
|
+
const structEntries = Object.entries(structGroups).map(([k, v]) => ({ structure: k, avg: Math.round(v.total / v.count * 10) / 10, count: v.count }));
|
|
3272
|
+
if (structEntries.length >= 2) {
|
|
3273
|
+
structEntries.sort((a, b) => b.avg - a.avg);
|
|
3274
|
+
const best = structEntries[0];
|
|
3275
|
+
const worst = structEntries[structEntries.length - 1];
|
|
3276
|
+
const delta = Math.round((best.avg - worst.avg) * 10) / 10;
|
|
3277
|
+
console.log(`\u{1F3D7}\uFE0F Structure:`);
|
|
3278
|
+
for (const s of structEntries) {
|
|
3279
|
+
console.log(` "${s.structure}" avg score ${s.avg} (${s.count} notes)`);
|
|
3280
|
+
}
|
|
3281
|
+
if (delta > 0) console.log(` \u2192 Posts with '${best.structure}' structure score ${delta} points higher than '${worst.structure}'`);
|
|
3282
|
+
console.log();
|
|
3283
|
+
}
|
|
3284
|
+
const toneGroups = {};
|
|
3285
|
+
for (const n of notes2) {
|
|
3286
|
+
if (!n.tone) continue;
|
|
3287
|
+
if (!toneGroups[n.tone]) toneGroups[n.tone] = { total: 0, count: 0 };
|
|
3288
|
+
toneGroups[n.tone].total += n.score;
|
|
3289
|
+
toneGroups[n.tone].count += 1;
|
|
3290
|
+
}
|
|
3291
|
+
const toneEntries = Object.entries(toneGroups).map(([k, v]) => ({ tone: k, avg: Math.round(v.total / v.count * 10) / 10, count: v.count }));
|
|
3292
|
+
if (toneEntries.length >= 2) {
|
|
3293
|
+
toneEntries.sort((a, b) => b.avg - a.avg);
|
|
3294
|
+
console.log(`\u{1F3AD} Tone:`);
|
|
3295
|
+
for (const t of toneEntries) {
|
|
3296
|
+
console.log(` "${t.tone}" avg score ${t.avg} (${t.count} notes)`);
|
|
3297
|
+
}
|
|
3298
|
+
console.log();
|
|
3299
|
+
} else if (toneEntries.length === 1) {
|
|
3300
|
+
console.log(`\u{1F3AD} Tone: all feedback notes use "${toneEntries[0].tone}" (avg ${toneEntries[0].avg})`);
|
|
3301
|
+
console.log();
|
|
3302
|
+
}
|
|
3303
|
+
const platformMetrics = {};
|
|
3304
|
+
for (const fb of allFeedback) {
|
|
3305
|
+
for (const e of fb.entries) {
|
|
3306
|
+
if (!e.platform) continue;
|
|
3307
|
+
if (!platformMetrics[e.platform]) platformMetrics[e.platform] = { views: 0, likes: 0, shares: 0, comments: 0, count: 0 };
|
|
3308
|
+
const pm = platformMetrics[e.platform];
|
|
3309
|
+
pm.views += e.metrics.views || 0;
|
|
3310
|
+
pm.likes += e.metrics.likes || 0;
|
|
3311
|
+
pm.shares += e.metrics.shares || 0;
|
|
3312
|
+
pm.comments += e.metrics.comments || 0;
|
|
3313
|
+
pm.count += 1;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
const platEntries = Object.entries(platformMetrics);
|
|
3317
|
+
if (platEntries.length >= 2) {
|
|
3318
|
+
console.log(`\u{1F310} Platform Comparison:`);
|
|
3319
|
+
for (const [platform, pm] of platEntries) {
|
|
3320
|
+
console.log(` ${platform}: ${pm.views} views, ${pm.likes} likes, ${pm.shares} shares, ${pm.comments} comments (${pm.count} entries)`);
|
|
3321
|
+
}
|
|
3322
|
+
const platShareAvgs = platEntries.filter(([, pm]) => pm.count > 0).map(([platform, pm]) => ({ platform, avgShares: Math.round(pm.shares / pm.count * 10) / 10 })).sort((a, b) => b.avgShares - a.avgShares);
|
|
3323
|
+
if (platShareAvgs.length >= 2 && platShareAvgs[platShareAvgs.length - 1].avgShares > 0) {
|
|
3324
|
+
const ratio = Math.round(platShareAvgs[0].avgShares / platShareAvgs[platShareAvgs.length - 1].avgShares * 10) / 10;
|
|
3325
|
+
console.log(` \u2192 ${platShareAvgs[0].platform} gets ${ratio}x more shares than ${platShareAvgs[platShareAvgs.length - 1].platform}`);
|
|
3326
|
+
}
|
|
3327
|
+
console.log();
|
|
3328
|
+
}
|
|
3329
|
+
const withWordCount = notes2.filter((n) => n.markers?.wordCount);
|
|
3330
|
+
if (withWordCount.length >= 2) {
|
|
3331
|
+
const sortedByScore = [...withWordCount].sort((a, b) => a.score - b.score);
|
|
3332
|
+
const topHalf = sortedByScore.slice(Math.floor(sortedByScore.length / 2));
|
|
3333
|
+
const bottomHalf = sortedByScore.slice(0, Math.floor(sortedByScore.length / 2));
|
|
3334
|
+
if (topHalf.length > 0 && bottomHalf.length > 0) {
|
|
3335
|
+
const topAvgWC = Math.round(topHalf.reduce((s, n) => s + n.markers.wordCount, 0) / topHalf.length);
|
|
3336
|
+
const botAvgWC = Math.round(bottomHalf.reduce((s, n) => s + n.markers.wordCount, 0) / bottomHalf.length);
|
|
3337
|
+
console.log(`\u{1F4DD} Word Count:`);
|
|
3338
|
+
console.log(` Top-scoring posts average ${topAvgWC} words (vs ${botAvgWC} for lowest-scoring)`);
|
|
3339
|
+
console.log();
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
const withReadability = notes2.filter((n) => n.markers?.readabilityScore);
|
|
3343
|
+
if (withReadability.length >= 2) {
|
|
3344
|
+
const sortedByScore = [...withReadability].sort((a, b) => a.score - b.score);
|
|
3345
|
+
const topHalf = sortedByScore.slice(Math.floor(sortedByScore.length / 2));
|
|
3346
|
+
const bottomHalf = sortedByScore.slice(0, Math.floor(sortedByScore.length / 2));
|
|
3347
|
+
if (topHalf.length > 0 && bottomHalf.length > 0) {
|
|
3348
|
+
const topAvgRd = Math.round(topHalf.reduce((s, n) => s + n.markers.readabilityScore, 0) / topHalf.length);
|
|
3349
|
+
const botAvgRd = Math.round(bottomHalf.reduce((s, n) => s + n.markers.readabilityScore, 0) / bottomHalf.length);
|
|
3350
|
+
console.log(`\u{1F4D6} Readability:`);
|
|
3351
|
+
console.log(` Top-scoring posts: ${topAvgRd}/100 FK score vs ${botAvgRd}/100 for lowest-scoring`);
|
|
3352
|
+
console.log();
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
console.log(`\u{1F4A1} Tip: Record more feedback to improve correlations. Run: openclaw notes feedback <slug> --score 8 --platform linkedin`);
|
|
3356
|
+
});
|
|
3357
|
+
notes.command("subscribe [url]").description("Subscribe to another PressClaw instance's agent feed").action(async (url) => {
|
|
3358
|
+
const cfg = resolveConfig(api);
|
|
3359
|
+
const subs = readSubscriptions(cfg.notesDir);
|
|
3360
|
+
if (!url) {
|
|
3361
|
+
if (subs.length === 0) {
|
|
3362
|
+
console.log("No subscriptions yet.");
|
|
3363
|
+
console.log("Add one: openclaw notes subscribe <url>");
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
console.log(`\u{1F4E1} Subscriptions (${subs.length}):
|
|
3367
|
+
`);
|
|
3368
|
+
for (const sub of subs) {
|
|
3369
|
+
const checked = sub.last_checked ? `last checked ${sub.last_checked}` : "never checked";
|
|
3370
|
+
console.log(` ${sub.name}`);
|
|
3371
|
+
console.log(` ${sub.url}`);
|
|
3372
|
+
console.log(` Feed: ${sub.feed_url}`);
|
|
3373
|
+
console.log(` Added: ${sub.added} \xB7 ${checked}
|
|
3374
|
+
`);
|
|
3375
|
+
}
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
let baseUrl = url.replace(/\/+$/, "");
|
|
3379
|
+
if (!baseUrl.startsWith("http")) baseUrl = `https://${baseUrl}`;
|
|
3380
|
+
if (subs.find((s) => s.url === baseUrl)) {
|
|
3381
|
+
console.log(`Already subscribed to ${baseUrl}`);
|
|
3382
|
+
return;
|
|
3383
|
+
}
|
|
3384
|
+
console.log(`Fetching ${baseUrl}/.well-known/pressclaw.json ...`);
|
|
3385
|
+
try {
|
|
3386
|
+
const resp = await fetch(`${baseUrl}/.well-known/pressclaw.json`);
|
|
3387
|
+
if (!resp.ok) {
|
|
3388
|
+
console.log(`\u274C Could not find PressClaw instance at ${baseUrl}`);
|
|
3389
|
+
console.log(` HTTP ${resp.status}: ${resp.statusText}`);
|
|
3390
|
+
return;
|
|
3391
|
+
}
|
|
3392
|
+
const wellKnown = await resp.json();
|
|
3393
|
+
if (!wellKnown.feed_url) {
|
|
3394
|
+
console.log(`\u274C Invalid pressclaw.json \u2014 missing feed_url`);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
const sub = {
|
|
3398
|
+
url: baseUrl,
|
|
3399
|
+
name: wellKnown.name || baseUrl,
|
|
3400
|
+
feed_url: wellKnown.feed_url,
|
|
3401
|
+
added: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3402
|
+
last_checked: null
|
|
3403
|
+
};
|
|
3404
|
+
subs.push(sub);
|
|
3405
|
+
writeSubscriptions(cfg.notesDir, subs);
|
|
3406
|
+
console.log(`\u2705 Subscribed to "${sub.name}"`);
|
|
3407
|
+
console.log(` Feed: ${sub.feed_url}`);
|
|
3408
|
+
if (wellKnown.post_count) console.log(` Posts: ${wellKnown.post_count}`);
|
|
3409
|
+
if (wellKnown.topics?.length) console.log(` Topics: ${wellKnown.topics.join(", ")}`);
|
|
3410
|
+
} catch (err) {
|
|
3411
|
+
console.log(`\u274C Failed to connect to ${baseUrl}`);
|
|
3412
|
+
if (err?.message) console.log(` Error: ${err.message}`);
|
|
3413
|
+
}
|
|
3414
|
+
});
|
|
3415
|
+
notes.command("unsubscribe <url>").description("Remove a subscription").action((url) => {
|
|
3416
|
+
const cfg = resolveConfig(api);
|
|
3417
|
+
let baseUrl = url.replace(/\/+$/, "");
|
|
3418
|
+
if (!baseUrl.startsWith("http")) baseUrl = `https://${baseUrl}`;
|
|
3419
|
+
const subs = readSubscriptions(cfg.notesDir);
|
|
3420
|
+
const before = subs.length;
|
|
3421
|
+
const filtered = subs.filter((s) => s.url !== baseUrl);
|
|
3422
|
+
if (filtered.length === before) {
|
|
3423
|
+
console.log(`Not subscribed to ${baseUrl}`);
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
writeSubscriptions(cfg.notesDir, filtered);
|
|
3427
|
+
console.log(`\u2705 Unsubscribed from ${baseUrl}`);
|
|
3428
|
+
});
|
|
3429
|
+
notes.command("digest").description("Fetch new posts from all subscribed feeds").action(async () => {
|
|
3430
|
+
const cfg = resolveConfig(api);
|
|
3431
|
+
const subs = readSubscriptions(cfg.notesDir);
|
|
3432
|
+
if (subs.length === 0) {
|
|
3433
|
+
console.log("No subscriptions. Add one: openclaw notes subscribe <url>");
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
console.log(`\u{1F4E1} Checking ${subs.length} feed(s)...
|
|
3437
|
+
`);
|
|
3438
|
+
let totalNew = 0;
|
|
3439
|
+
let feedsWithNew = 0;
|
|
3440
|
+
const allNewPosts = [];
|
|
3441
|
+
for (const sub of subs) {
|
|
3442
|
+
try {
|
|
3443
|
+
const resp = await fetch(sub.feed_url);
|
|
3444
|
+
if (!resp.ok) {
|
|
3445
|
+
console.log(` \u26A0\uFE0F ${sub.name}: HTTP ${resp.status}`);
|
|
3446
|
+
continue;
|
|
3447
|
+
}
|
|
3448
|
+
const feed = await resp.json();
|
|
3449
|
+
const posts = feed.posts || [];
|
|
3450
|
+
const since = sub.last_checked ? new Date(sub.last_checked).getTime() : 0;
|
|
3451
|
+
const newPosts = posts.filter((p) => {
|
|
3452
|
+
const pubTime = new Date(p.published_at).getTime();
|
|
3453
|
+
return pubTime > since;
|
|
3454
|
+
});
|
|
3455
|
+
if (newPosts.length > 0) {
|
|
3456
|
+
feedsWithNew++;
|
|
3457
|
+
totalNew += newPosts.length;
|
|
3458
|
+
for (const p of newPosts) {
|
|
3459
|
+
allNewPosts.push({
|
|
3460
|
+
source: sub.name,
|
|
3461
|
+
title: p.title,
|
|
3462
|
+
topics: p.topics || [],
|
|
3463
|
+
summary: p.summary || "",
|
|
3464
|
+
url: p.url || "",
|
|
3465
|
+
published_at: p.published_at
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
sub.last_checked = (/* @__PURE__ */ new Date()).toISOString();
|
|
3470
|
+
} catch (err) {
|
|
3471
|
+
console.log(` \u26A0\uFE0F ${sub.name}: ${err?.message || "fetch failed"}`);
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
writeSubscriptions(cfg.notesDir, subs);
|
|
3475
|
+
if (totalNew === 0) {
|
|
3476
|
+
console.log("No new posts since last check.");
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
console.log(`\u{1F4EC} ${totalNew} new post(s) from ${feedsWithNew} feed(s):
|
|
3480
|
+
`);
|
|
3481
|
+
for (const p of allNewPosts) {
|
|
3482
|
+
const topics = p.topics.length > 0 ? ` [${p.topics.join(", ")}]` : "";
|
|
3483
|
+
console.log(` \u{1F4DD} "${p.title}" \u2014 ${p.source}${topics}`);
|
|
3484
|
+
console.log(` ${p.summary.slice(0, 120)}${p.summary.length > 120 ? "\u2026" : ""}`);
|
|
3485
|
+
if (p.url) console.log(` ${p.url}`);
|
|
3486
|
+
console.log();
|
|
3487
|
+
}
|
|
3488
|
+
});
|
|
1620
3489
|
}, { commands: ["notes"] });
|
|
1621
3490
|
const sendFile = (res, filePath, contentType) => {
|
|
1622
3491
|
if (!fs.existsSync(filePath)) {
|
|
@@ -1639,6 +3508,24 @@ Next: openclaw notes refine ${slug}`);
|
|
|
1639
3508
|
res.end(html);
|
|
1640
3509
|
return true;
|
|
1641
3510
|
}
|
|
3511
|
+
if (pathname === "/feed/agent.json") {
|
|
3512
|
+
const feed = buildAgentFeed(cfg);
|
|
3513
|
+
const json = JSON.stringify(feed, null, 2);
|
|
3514
|
+
res.statusCode = 200;
|
|
3515
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
3516
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
3517
|
+
res.end(json);
|
|
3518
|
+
return true;
|
|
3519
|
+
}
|
|
3520
|
+
if (pathname === "/.well-known/pressclaw.json") {
|
|
3521
|
+
const wellKnown = buildWellKnown(cfg);
|
|
3522
|
+
const json = JSON.stringify(wellKnown, null, 2);
|
|
3523
|
+
res.statusCode = 200;
|
|
3524
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
3525
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
3526
|
+
res.end(json);
|
|
3527
|
+
return true;
|
|
3528
|
+
}
|
|
1642
3529
|
if (!pathname.startsWith(cfg.publicPath)) return false;
|
|
1643
3530
|
if (pathname === cfg.publicPath || pathname === `${cfg.publicPath}/`) {
|
|
1644
3531
|
sendFile(res, path.join(cfg.outputDir, "index.html"), "text/html; charset=utf-8");
|
|
@@ -1697,6 +3584,9 @@ function renderDashboardHtml(cfg, pluginDir) {
|
|
|
1697
3584
|
}
|
|
1698
3585
|
}
|
|
1699
3586
|
}
|
|
3587
|
+
const allFeedback = loadAllFeedback(cfg.notesDir);
|
|
3588
|
+
const feedbackBySlug = {};
|
|
3589
|
+
for (const fb of allFeedback) feedbackBySlug[fb.slug] = fb;
|
|
1700
3590
|
const noteDetails = [];
|
|
1701
3591
|
for (const f of files) {
|
|
1702
3592
|
const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
|
|
@@ -1722,7 +3612,8 @@ function renderDashboardHtml(cfg, pluginDir) {
|
|
|
1722
3612
|
structure: meta.structure || null,
|
|
1723
3613
|
topicStatus: topic?.status || "untracked",
|
|
1724
3614
|
testResult: testResults[slug] || null,
|
|
1725
|
-
styleMarkers: sm
|
|
3615
|
+
styleMarkers: sm,
|
|
3616
|
+
feedback: feedbackBySlug[slug] || null
|
|
1726
3617
|
});
|
|
1727
3618
|
}
|
|
1728
3619
|
const statusIcon = (s) => s === "public" ? "\u{1F4E2}" : s === "refined" ? "\u2728" : "\u{1F512}";
|
|
@@ -1742,6 +3633,13 @@ function renderDashboardHtml(cfg, pluginDir) {
|
|
|
1742
3633
|
}).join(" ") : "";
|
|
1743
3634
|
const sm = n.styleMarkers;
|
|
1744
3635
|
const markersHtml = sm ? `<span style="font-size:0.75em;color:#a1a1aa">${sm.avgSentenceLength}w/sent \xB7 ${sm.avgParagraphSentences}s/\xB6 \xB7 ${sm.readabilityScore} FK \xB7 ${sm.perspective}</span>` : '<span style="color:#3f3f46;font-size:0.75em">\u2014</span>';
|
|
3636
|
+
const fbHtml = n.feedback ? `<span style="font-size:0.95em;font-weight:600;color:${n.feedback.aggregate.avgScore >= 7 ? "#22c55e" : n.feedback.aggregate.avgScore >= 5 ? "#eab308" : "#ef4444"}">${n.feedback.aggregate.avgScore}/10</span><br><span style="font-size:0.75em;color:#71717a">${n.feedback.aggregate.totalViews}v \xB7 ${n.feedback.aggregate.totalLikes}\u2665 \xB7 ${n.feedback.aggregate.totalShares}\u2197</span>` : '<span style="color:#3f3f46">\u2014</span>';
|
|
3637
|
+
const twitterEntry = n.feedback?.entries?.find((e) => e.platform === "twitter");
|
|
3638
|
+
const engagementHtml = twitterEntry ? `<br><span style="font-size:0.7em;color:#71717a" title="Last fetched engagement">\u{1F4CA} ${twitterEntry.metrics.views || 0}v \xB7 ${twitterEntry.metrics.likes || 0}\u2665 \xB7 ${twitterEntry.metrics.shares || 0}\u2197 \xB7 ${twitterEntry.metrics.comments || 0}\u{1F4AC}</span>` : "";
|
|
3639
|
+
const twitterPosts = n.feedback?.tweets?.filter((t) => !t.platform || t.platform === "twitter") || [];
|
|
3640
|
+
const tweetHtml = twitterPosts.length ? `<a href="https://x.com/pressclawai/status/${twitterPosts[twitterPosts.length - 1].id}" target="_blank" style="text-decoration:none" title="Posted ${twitterPosts.length} tweet(s)">\u{1F426} ${twitterPosts.length}</a>${engagementHtml}` : '<span style="color:#3f3f46">\u2014</span>';
|
|
3641
|
+
const linkedinPosts = n.feedback?.tweets?.filter((t) => t.platform === "linkedin") || [];
|
|
3642
|
+
const linkedinHtml = linkedinPosts.length ? `<a href="https://www.linkedin.com/feed/update/${linkedinPosts[linkedinPosts.length - 1].id}" target="_blank" style="text-decoration:none" title="Posted ${linkedinPosts.length} LinkedIn post(s)">\u{1F4BC} ${linkedinPosts.length}</a>` : '<span style="color:#3f3f46">\u2014</span>';
|
|
1745
3643
|
return `<tr>
|
|
1746
3644
|
<td>${statusIcon(n.status)}</td>
|
|
1747
3645
|
<td><strong>${escapeHtml(n.title)}</strong><br><span style="color:#71717a;font-size:0.85em">${n.slug}</span>${n.testResult?.topSuggestion ? `<br><span style="color:#a1a1aa;font-size:0.8em;font-style:italic">\u{1F4A1} ${escapeHtml(n.testResult.topSuggestion.slice(0, 120))}${n.testResult.topSuggestion.length > 120 ? "\u2026" : ""}</span>` : ""}</td>
|
|
@@ -1751,6 +3649,9 @@ function renderDashboardHtml(cfg, pluginDir) {
|
|
|
1751
3649
|
<td>${n.structure || "\u2014"}</td>
|
|
1752
3650
|
<td>${markersHtml}</td>
|
|
1753
3651
|
<td>${n.variations > 0 ? n.variations + " vars" : "\u2014"}</td>
|
|
3652
|
+
<td>${fbHtml}</td>
|
|
3653
|
+
<td>${tweetHtml}</td>
|
|
3654
|
+
<td>${linkedinHtml}</td>
|
|
1754
3655
|
<td>${confBar(n.confidence)}${testScores ? `<br><span style="font-size:0.75em;color:#71717a">${testScores}</span>` : ""}</td>
|
|
1755
3656
|
</tr>`;
|
|
1756
3657
|
}).join("\n");
|
|
@@ -1822,12 +3723,16 @@ function renderDashboardHtml(cfg, pluginDir) {
|
|
|
1822
3723
|
|
|
1823
3724
|
<h2>\u{1F4DD} Notes Pipeline</h2>
|
|
1824
3725
|
<table>
|
|
1825
|
-
<thead><tr><th></th><th>Title</th><th>Status</th><th>Words</th><th>Tone</th><th>Structure</th><th>Style DNA</th><th>Vars</th><th>Confidence</th></tr></thead>
|
|
3726
|
+
<thead><tr><th></th><th>Title</th><th>Status</th><th>Words</th><th>Tone</th><th>Structure</th><th>Style DNA</th><th>Vars</th><th>Score</th><th>\u{1F426}</th><th>\u{1F4BC}</th><th>Confidence</th></tr></thead>
|
|
1826
3727
|
<tbody>${noteRows}</tbody>
|
|
1827
3728
|
</table>
|
|
1828
3729
|
|
|
1829
3730
|
${_renderEvolutionHtml(styleProfile)}
|
|
1830
3731
|
|
|
3732
|
+
${_renderPerformanceHtml(cfg.notesDir, allFeedback, noteDetails)}
|
|
3733
|
+
|
|
3734
|
+
${_renderAgentFeedHtml(cfg)}
|
|
3735
|
+
|
|
1831
3736
|
${ideas.length > 0 ? `
|
|
1832
3737
|
<h2>\u{1F4A1} Topic Backlog (${ideas.length})</h2>
|
|
1833
3738
|
<table>
|
|
@@ -1837,11 +3742,41 @@ function renderDashboardHtml(cfg, pluginDir) {
|
|
|
1837
3742
|
` : ""}
|
|
1838
3743
|
|
|
1839
3744
|
<div class="footer">
|
|
1840
|
-
Generated ${(/* @__PURE__ */ new Date()).toISOString()} \xB7 <a href="${cfg.publicPath}/rss.xml">RSS</a>
|
|
3745
|
+
Generated ${(/* @__PURE__ */ new Date()).toISOString()} \xB7 <a href="${cfg.publicPath}/rss.xml">RSS</a> \xB7 <a href="/feed/agent.json">Agent Feed</a>
|
|
1841
3746
|
</div>
|
|
1842
3747
|
</body>
|
|
1843
3748
|
</html>`;
|
|
1844
3749
|
}
|
|
3750
|
+
function _renderAgentFeedHtml(cfg) {
|
|
3751
|
+
const subs = readSubscriptions(cfg.notesDir);
|
|
3752
|
+
const feedUrl = cfg.baseUrl ? `${cfg.baseUrl}/feed/agent.json` : "/feed/agent.json";
|
|
3753
|
+
const wellKnownUrl = cfg.baseUrl ? `${cfg.baseUrl}/.well-known/pressclaw.json` : "/.well-known/pressclaw.json";
|
|
3754
|
+
let lastDigest = "never";
|
|
3755
|
+
for (const sub of subs) {
|
|
3756
|
+
if (sub.last_checked && (lastDigest === "never" || sub.last_checked > lastDigest)) {
|
|
3757
|
+
lastDigest = sub.last_checked;
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
const subRows = subs.length > 0 ? subs.map((s) => {
|
|
3761
|
+
const checked = s.last_checked ? new Date(s.last_checked).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
3762
|
+
return `<tr><td>${escapeHtml(s.name)}</td><td><a href="${escapeHtml(s.url)}">${escapeHtml(s.url)}</a></td><td>${checked}</td></tr>`;
|
|
3763
|
+
}).join("\n") : `<tr><td colspan="3" style="color:#52525b">No subscriptions yet. Run: <code>openclaw notes subscribe <url></code></td></tr>`;
|
|
3764
|
+
return `
|
|
3765
|
+
<h2>\u{1F4E1} Agent Feed</h2>
|
|
3766
|
+
<div class="style-box">
|
|
3767
|
+
<div style="display:flex;gap:24px;flex-wrap:wrap;margin-bottom:12px">
|
|
3768
|
+
<div><span style="color:#71717a;font-size:0.8em">Feed URL</span><br><a href="${escapeHtml(feedUrl)}" style="font-size:0.9em">${escapeHtml(feedUrl)}</a></div>
|
|
3769
|
+
<div><span style="color:#71717a;font-size:0.8em">Discovery</span><br><a href="${escapeHtml(wellKnownUrl)}" style="font-size:0.9em">.well-known/pressclaw.json</a></div>
|
|
3770
|
+
<div><span style="color:#71717a;font-size:0.8em">Subscriptions</span><br><span style="font-size:1.2em;font-weight:600">${subs.length}</span></div>
|
|
3771
|
+
<div><span style="color:#71717a;font-size:0.8em">Last Digest</span><br><span style="font-size:0.9em">${lastDigest === "never" ? "never" : new Date(lastDigest).toISOString().slice(0, 16).replace("T", " ")}</span></div>
|
|
3772
|
+
</div>
|
|
3773
|
+
${subs.length > 0 ? `
|
|
3774
|
+
<table style="margin-top:8px">
|
|
3775
|
+
<thead><tr><th>Name</th><th>URL</th><th>Last Checked</th></tr></thead>
|
|
3776
|
+
<tbody>${subRows}</tbody>
|
|
3777
|
+
</table>` : ""}
|
|
3778
|
+
</div>`;
|
|
3779
|
+
}
|
|
1845
3780
|
function _renderEvolutionHtml(styleProfile) {
|
|
1846
3781
|
if (!styleProfile?.evolution?.length || styleProfile.evolution.length < 2) return "";
|
|
1847
3782
|
const evo = styleProfile.evolution;
|
|
@@ -1865,6 +3800,80 @@ function _renderEvolutionHtml(styleProfile) {
|
|
|
1865
3800
|
}).join("");
|
|
1866
3801
|
return '<h2>\u{1F4C8} Style Evolution</h2>\n<div class="style-box"><div style="display:flex;gap:24px;flex-wrap:wrap;margin-bottom:12px"><div><span style="color:#71717a;font-size:0.8em">Sentence length</span><br><span style="font-size:1.2em;font-weight:600">' + latest.avgSentenceLength + 'w</span> <span style="font-size:0.8em;color:' + slColor + '">' + slArrow + slDelta + '</span></div><div><span style="color:#71717a;font-size:0.8em">Avg word count</span><br><span style="font-size:1.2em;font-weight:600">' + Math.round(latest.avgWordCount) + '</span> <span style="font-size:0.8em;color:' + wcColor + '">' + wcArrow + wcDelta + '</span></div><div><span style="color:#71717a;font-size:0.8em">Readability</span><br><span style="font-size:1.2em;font-weight:600">' + latest.avgReadability + '/100</span> <span style="font-size:0.8em;color:' + rdColor + '">' + rdArrow + rdDelta + '</span></div><div><span style="color:#71717a;font-size:0.8em">Notes analyzed</span><br><span style="font-size:1.2em;font-weight:600">' + latest.noteCount + '</span></div></div><div style="display:flex;align-items:end;gap:3px;height:24px">' + sparkBars + '</div><div style="font-size:0.75em;color:#52525b;margin-top:4px">Word count trend (last ' + recentEvo.length + " snapshots) \xB7 First: " + first.date + " \xB7 Latest: " + latest.date + "</div></div>";
|
|
1867
3802
|
}
|
|
3803
|
+
function _renderPerformanceHtml(notesDir, allFeedback, noteDetails) {
|
|
3804
|
+
if (allFeedback.length === 0) return "";
|
|
3805
|
+
const withFeedback = noteDetails.filter((n) => n.feedback && n.feedback.aggregate.entries > 0);
|
|
3806
|
+
if (withFeedback.length === 0) return "";
|
|
3807
|
+
const topPerformers = [...withFeedback].sort((a, b) => b.feedback.aggregate.avgScore - a.feedback.aggregate.avgScore).slice(0, 5);
|
|
3808
|
+
const topRows = topPerformers.map((n) => {
|
|
3809
|
+
const fb = n.feedback;
|
|
3810
|
+
const scoreColor = fb.aggregate.avgScore >= 7 ? "#22c55e" : fb.aggregate.avgScore >= 5 ? "#eab308" : "#ef4444";
|
|
3811
|
+
return `<tr>
|
|
3812
|
+
<td style="font-weight:600;color:${scoreColor}">${fb.aggregate.avgScore}/10</td>
|
|
3813
|
+
<td><strong>${escapeHtml(n.title)}</strong><br><span style="color:#71717a;font-size:0.85em">${n.slug}</span></td>
|
|
3814
|
+
<td>${fb.aggregate.totalViews}</td>
|
|
3815
|
+
<td>${fb.aggregate.totalLikes}</td>
|
|
3816
|
+
<td>${fb.aggregate.totalShares}</td>
|
|
3817
|
+
<td>${fb.aggregate.entries}</td>
|
|
3818
|
+
</tr>`;
|
|
3819
|
+
}).join("\n");
|
|
3820
|
+
const correlations = [];
|
|
3821
|
+
const toneGroups = {};
|
|
3822
|
+
for (const n of withFeedback) {
|
|
3823
|
+
if (!n.tone) continue;
|
|
3824
|
+
if (!toneGroups[n.tone]) toneGroups[n.tone] = { total: 0, count: 0 };
|
|
3825
|
+
toneGroups[n.tone].total += n.feedback.aggregate.avgScore;
|
|
3826
|
+
toneGroups[n.tone].count += 1;
|
|
3827
|
+
}
|
|
3828
|
+
const toneEntries = Object.entries(toneGroups).map(([k, v]) => ({ tone: k, avg: Math.round(v.total / v.count * 10) / 10 })).sort((a, b) => b.avg - a.avg);
|
|
3829
|
+
if (toneEntries.length >= 2) {
|
|
3830
|
+
correlations.push(`\u{1F3AD} <strong>${escapeHtml(toneEntries[0].tone)}</strong> tone scores ${toneEntries[0].avg} avg vs <strong>${escapeHtml(toneEntries[toneEntries.length - 1].tone)}</strong> at ${toneEntries[toneEntries.length - 1].avg}`);
|
|
3831
|
+
}
|
|
3832
|
+
const structGroups = {};
|
|
3833
|
+
for (const n of withFeedback) {
|
|
3834
|
+
if (!n.structure) continue;
|
|
3835
|
+
if (!structGroups[n.structure]) structGroups[n.structure] = { total: 0, count: 0 };
|
|
3836
|
+
structGroups[n.structure].total += n.feedback.aggregate.avgScore;
|
|
3837
|
+
structGroups[n.structure].count += 1;
|
|
3838
|
+
}
|
|
3839
|
+
const structEntries = Object.entries(structGroups).map(([k, v]) => ({ structure: k, avg: Math.round(v.total / v.count * 10) / 10 })).sort((a, b) => b.avg - a.avg);
|
|
3840
|
+
if (structEntries.length >= 2) {
|
|
3841
|
+
const delta = Math.round((structEntries[0].avg - structEntries[structEntries.length - 1].avg) * 10) / 10;
|
|
3842
|
+
correlations.push(`\u{1F3D7}\uFE0F <strong>${escapeHtml(structEntries[0].structure)}</strong> structure scores ${delta} points higher than <strong>${escapeHtml(structEntries[structEntries.length - 1].structure)}</strong>`);
|
|
3843
|
+
}
|
|
3844
|
+
const withMarkers = withFeedback.filter((n) => n.styleMarkers?.avgSentenceLength);
|
|
3845
|
+
if (withMarkers.length >= 2) {
|
|
3846
|
+
const sorted = [...withMarkers].sort((a, b) => a.feedback.aggregate.avgScore - b.feedback.aggregate.avgScore);
|
|
3847
|
+
const topHalf = sorted.slice(Math.floor(sorted.length / 2));
|
|
3848
|
+
const bottomHalf = sorted.slice(0, Math.floor(sorted.length / 2));
|
|
3849
|
+
if (topHalf.length > 0 && bottomHalf.length > 0) {
|
|
3850
|
+
const topSL = Math.round(topHalf.reduce((s, n) => s + n.styleMarkers.avgSentenceLength, 0) / topHalf.length * 10) / 10;
|
|
3851
|
+
const botSL = Math.round(bottomHalf.reduce((s, n) => s + n.styleMarkers.avgSentenceLength, 0) / bottomHalf.length * 10) / 10;
|
|
3852
|
+
correlations.push(`\u{1F4CF} Top-rated posts avg <strong>${topSL}</strong> words/sentence (vs ${botSL} for lowest)`);
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
const platformMetrics = {};
|
|
3856
|
+
for (const fb of allFeedback) {
|
|
3857
|
+
for (const e of fb.entries) {
|
|
3858
|
+
if (!e.platform) continue;
|
|
3859
|
+
if (!platformMetrics[e.platform]) platformMetrics[e.platform] = { shares: 0, count: 0 };
|
|
3860
|
+
platformMetrics[e.platform].shares += e.metrics.shares || 0;
|
|
3861
|
+
platformMetrics[e.platform].count += 1;
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
const platEntries = Object.entries(platformMetrics).filter(([, pm]) => pm.count > 0).map(([p, pm]) => ({ platform: p, avgShares: Math.round(pm.shares / pm.count * 10) / 10 })).sort((a, b) => b.avgShares - a.avgShares);
|
|
3865
|
+
if (platEntries.length >= 2 && platEntries[platEntries.length - 1].avgShares > 0) {
|
|
3866
|
+
const ratio = Math.round(platEntries[0].avgShares / platEntries[platEntries.length - 1].avgShares * 10) / 10;
|
|
3867
|
+
correlations.push(`\u{1F310} <strong>${escapeHtml(platEntries[0].platform)}</strong> gets ${ratio}x more shares than ${escapeHtml(platEntries[platEntries.length - 1].platform)}`);
|
|
3868
|
+
}
|
|
3869
|
+
const corrHtml = correlations.length > 0 ? `<div class="style-box" style="margin-top:12px"><div style="font-size:0.85em;color:#a1a1aa;margin-bottom:8px">Style Correlations</div>${correlations.map((c) => `<div style="margin:4px 0;font-size:0.9em">${c}</div>`).join("")}</div>` : "";
|
|
3870
|
+
return `<h2>\u{1F4C8} Performance (${withFeedback.length} notes tracked)</h2>
|
|
3871
|
+
<table>
|
|
3872
|
+
<thead><tr><th>Score</th><th>Title</th><th>Views</th><th>Likes</th><th>Shares</th><th>Entries</th></tr></thead>
|
|
3873
|
+
<tbody>${topRows}</tbody>
|
|
3874
|
+
</table>
|
|
3875
|
+
${corrHtml}`;
|
|
3876
|
+
}
|
|
1868
3877
|
function _noteConfidence(notesDir, slug) {
|
|
1869
3878
|
if (!slug) return null;
|
|
1870
3879
|
const file = path.join(notesDir, `${slug}.md`);
|