openads-ai 0.1.1 ā 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -3
- package/dist/cli.js +52 -7
- package/dist/report-template.js +628 -0
- package/dist/schedule.js +511 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,16 @@ OpenAds is an **open-source CLI tool** that turns any AI model into a marketing
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## šø Screenshots
|
|
43
|
+
|
|
44
|
+
Here is a look at OpenAds in action:
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
<img src="docs/images/screenshot.png" alt="OpenAds Welcome Screen" width="600" />
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
42
52
|
## ā” Quick Start
|
|
43
53
|
|
|
44
54
|
### 1. Install
|
|
@@ -108,6 +118,52 @@ Here are some real examples ā just type what you need:
|
|
|
108
118
|
|
|
109
119
|
---
|
|
110
120
|
|
|
121
|
+
## š§ Memory ā Gets Smarter Every Session
|
|
122
|
+
|
|
123
|
+
OpenAds remembers what it learns about your business. After each conversation, the AI appends key insights to a plain markdown file at `~/.openads/context/my-business.md`:
|
|
124
|
+
|
|
125
|
+
- Your best-performing campaigns and creative angles
|
|
126
|
+
- Audience segments and buying triggers
|
|
127
|
+
- Budget constraints and seasonal patterns
|
|
128
|
+
- Competitor insights and positioning gaps
|
|
129
|
+
|
|
130
|
+
You can open and edit this file anytime ā it's your data, not a black box. The longer you use OpenAds, the better its advice gets.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## ā° Scheduled Automations
|
|
135
|
+
|
|
136
|
+
Set up automated campaign checks that run in the background ā no server required.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
openads schedule
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Preset | Frequency |
|
|
143
|
+
|---|---|
|
|
144
|
+
| š Daily campaign health check | Every day at 8 AM |
|
|
145
|
+
| šø Budget pacing alert | Every 6 hours |
|
|
146
|
+
| š Performance drop alert | Twice daily (9 AM & 5 PM) |
|
|
147
|
+
| š Weekly performance report | Every Monday at 9 AM |
|
|
148
|
+
| ā° Custom (describe in plain English) | You choose |
|
|
149
|
+
|
|
150
|
+
Reports are saved to `~/.openads/reports/` in both Markdown and premium HTML dashboard formats. You can view, list, and open your reports directly:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
openads report # List all generated reports
|
|
154
|
+
openads report [name] # Open a beautiful HTML dashboard in your browser
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Manage your schedules:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
openads schedule # Open the schedule manager
|
|
161
|
+
openads schedule list # See active schedules
|
|
162
|
+
openads schedule remove # Remove a schedule
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Uses your OS scheduler (macOS `launchd` / Linux `crontab`) ā works even when your terminal is closed.
|
|
166
|
+
|
|
111
167
|
## š Security & Privacy
|
|
112
168
|
|
|
113
169
|
- **Runs 100% locally.** OpenAds is not a cloud service. Nothing leaves your machine except the API calls you authorize.
|
|
@@ -136,9 +192,13 @@ This verifies your config file, API keys, platform connections (live token check
|
|
|
136
192
|
- [x] Interactive setup wizard with live token verification
|
|
137
193
|
- [x] 12 pre-built skills: Ads, CRO, Copywriting, Analytics, Email, Video, Research, Strategy
|
|
138
194
|
- [x] Autonomous research loops
|
|
195
|
+
- [x] Published to npm (`npm install -g openads-ai`)
|
|
196
|
+
- [x] Memory system ā AI learns about your business over time
|
|
197
|
+
- [x] Scheduled automations ā daily health checks, budget alerts, weekly reports
|
|
198
|
+
- [ ] Telegram bot gateway ā talk to your ads from your phone
|
|
139
199
|
- [ ] LinkedIn Ads integration
|
|
140
|
-
- [ ]
|
|
141
|
-
- [ ]
|
|
200
|
+
- [ ] TikTok Ads integration (leveraging their new [TikTok Ads MCP Server](https://digiday.com/media/tiktok-world-ads-mcp-server/))
|
|
201
|
+
- [ ] Pinterest Ads integration (leveraging lessons/patterns from their [MCP Ecosystem](https://medium.com/pinterest-engineering/building-an-mcp-ecosystem-at-pinterest-c3b6b1b9e0f6))
|
|
142
202
|
|
|
143
203
|
---
|
|
144
204
|
|
|
@@ -162,4 +222,4 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) to get started.
|
|
|
162
222
|
|
|
163
223
|
MIT.
|
|
164
224
|
|
|
165
|
-
*Built on [Pi](https://github.com/earendil-works/pi) (MIT). Includes tools derived from [adloop](https://github.com/kLOsk/adloop) (MIT) by kLOsk. Marketing skills inspired by [marketingskills](https://github.com/coreyhaines31/marketingskills) (MIT) by Corey Haines.*
|
|
225
|
+
*Built on [Pi](https://github.com/earendil-works/pi) (MIT). Includes tools derived from [adloop](https://github.com/kLOsk/adloop) (MIT) by kLOsk. Marketing skills inspired by [marketingskills](https://github.com/coreyhaines31/marketingskills) (MIT) by Corey Haines. Memory and background automation concepts inspired by [Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research.*
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import gradient from 'gradient-string';
|
|
|
10
10
|
import ora from 'ora';
|
|
11
11
|
import { runSetup } from './setup.js';
|
|
12
12
|
import { runDoctor } from './doctor.js';
|
|
13
|
+
import { runScheduleManager, runScheduledTask, openReportInBrowser, listReports } from './schedule.js';
|
|
13
14
|
import enquirer from 'enquirer';
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
16
|
const __dirname = path.dirname(__filename);
|
|
@@ -44,9 +45,11 @@ function loadConfig() {
|
|
|
44
45
|
function resolveModel(provider) {
|
|
45
46
|
return DEPRECATED_MODELS[provider] || provider;
|
|
46
47
|
}
|
|
47
|
-
// āāā Product Context
|
|
48
|
-
//
|
|
49
|
-
//
|
|
48
|
+
// āāā Product Context & Memory āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
49
|
+
// The business context file grows over time. The agent appends learnings
|
|
50
|
+
// (audience insights, campaign results, winning creative angles, etc.)
|
|
51
|
+
// after each session. We only create the initial file if it doesn't exist
|
|
52
|
+
// so accumulated knowledge is never overwritten.
|
|
50
53
|
function injectProductContext(config) {
|
|
51
54
|
if (!config?.productContext)
|
|
52
55
|
return null;
|
|
@@ -55,7 +58,9 @@ function injectProductContext(config) {
|
|
|
55
58
|
fs.mkdirSync(contextDir, { recursive: true });
|
|
56
59
|
}
|
|
57
60
|
const contextPath = path.join(contextDir, 'my-business.md');
|
|
58
|
-
|
|
61
|
+
// Only create the initial file ā never overwrite accumulated learnings
|
|
62
|
+
if (!fs.existsSync(contextPath)) {
|
|
63
|
+
const content = `---
|
|
59
64
|
name: my-business
|
|
60
65
|
description: The user's business context ā always read this first.
|
|
61
66
|
---
|
|
@@ -63,15 +68,19 @@ description: The user's business context ā always read this first.
|
|
|
63
68
|
|
|
64
69
|
${config.productContext}
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
## Learnings
|
|
72
|
+
|
|
73
|
+
_The AI will automatically add insights here as it learns about your business._
|
|
74
|
+
_You can also edit this file manually at: ${contextPath}_
|
|
68
75
|
`;
|
|
69
|
-
|
|
76
|
+
fs.writeFileSync(contextPath, content);
|
|
77
|
+
}
|
|
70
78
|
return contextDir;
|
|
71
79
|
}
|
|
72
80
|
// āāā System Prompt āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
73
81
|
// Makes the agent behave as "OpenAds" instead of generic Pi.
|
|
74
82
|
function buildSystemPrompt(config) {
|
|
83
|
+
const contextPath = path.join(CONFIG_DIR, 'context', 'my-business.md');
|
|
75
84
|
const parts = [
|
|
76
85
|
'You are OpenAds, an AI marketing assistant built for digital marketers.',
|
|
77
86
|
'You specialize in Google Ads, Meta Ads, copywriting, analytics, CRO, and go-to-market strategy.',
|
|
@@ -79,6 +88,17 @@ function buildSystemPrompt(config) {
|
|
|
79
88
|
'Address the user as a marketing professional.',
|
|
80
89
|
'When writing ad copy or recommendations, always reference the user\'s product context first.',
|
|
81
90
|
'For any write operation (creating campaigns, changing budgets), always preview the change and ask for explicit confirmation before executing.',
|
|
91
|
+
'',
|
|
92
|
+
'## Memory',
|
|
93
|
+
'',
|
|
94
|
+
`Your business context file is at: ${contextPath}`,
|
|
95
|
+
'This file contains everything you have learned about the user\'s business across sessions.',
|
|
96
|
+
'At the START of every conversation, read this file to recall past context.',
|
|
97
|
+
'At the END of a conversation (or when you learn something significant), APPEND new insights to the "## Learnings" section of that file.',
|
|
98
|
+
'Things worth remembering: product details, audience segments, campaign performance benchmarks, winning ad angles, competitor insights, budget constraints, seasonal patterns, and any preferences the user expresses.',
|
|
99
|
+
'Format each learning as a bullet point with a date, e.g.: "- (2026-05-24) Best-performing Meta creative uses customer testimonial videos."',
|
|
100
|
+
'Never overwrite existing learnings ā only append new ones.',
|
|
101
|
+
'If the learnings section grows beyond 50 items, summarize the oldest 25 into a "## Summary" section at the top and remove the individual bullets.',
|
|
82
102
|
];
|
|
83
103
|
if (config?.productContext) {
|
|
84
104
|
parts.push(`\nThe user's business: ${config.productContext}`);
|
|
@@ -193,6 +213,26 @@ async function main() {
|
|
|
193
213
|
await runDoctor();
|
|
194
214
|
return;
|
|
195
215
|
}
|
|
216
|
+
if (args[0] === 'schedule') {
|
|
217
|
+
await runScheduleManager(args[1]);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (args[0] === 'run-schedule') {
|
|
221
|
+
await runScheduledTask(args[1]);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (args[0] === 'report') {
|
|
225
|
+
if (args[1] === 'list') {
|
|
226
|
+
listReports();
|
|
227
|
+
}
|
|
228
|
+
else if (args[1]) {
|
|
229
|
+
await openReportInBrowser(args[1]);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
listReports();
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
196
236
|
// āāā First-Run Detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
197
237
|
const config = loadConfig();
|
|
198
238
|
if (!config || !config.provider) {
|
|
@@ -241,6 +281,7 @@ async function main() {
|
|
|
241
281
|
{ name: 'autoresearch', message: `${chalk.cyan('š')} Test and improve ideas automatically ${chalk.gray('(autoresearch)')}` },
|
|
242
282
|
{ name: 'gtm', message: `${chalk.cyan('š')} Build a go-to-market plan ${chalk.gray('(strategy)')}` },
|
|
243
283
|
{ name: 'skills', message: `${chalk.cyan('š')} Browse available skills` },
|
|
284
|
+
{ name: 'schedule', message: `${chalk.cyan('ā°')} Schedule automations` },
|
|
244
285
|
{ name: 'setup', message: `${chalk.gray('āļø')} Settings` },
|
|
245
286
|
{ name: 'doctor', message: `${chalk.gray('š©ŗ')} Diagnostics` },
|
|
246
287
|
{ name: 'exit', message: `${chalk.gray('ā')} Exit` }
|
|
@@ -262,6 +303,10 @@ async function main() {
|
|
|
262
303
|
showSkills();
|
|
263
304
|
return;
|
|
264
305
|
}
|
|
306
|
+
if (action === 'schedule') {
|
|
307
|
+
await runScheduleManager();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
265
310
|
const actionMap = {
|
|
266
311
|
chat: [],
|
|
267
312
|
audit: ['audit-google-ads'],
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom inline style parser that converts standard markdown styling
|
|
3
|
+
* (bold, italic, code, checkbox) and typical keywords into premium HTML components/badges.
|
|
4
|
+
*/
|
|
5
|
+
function parseInlineStyles(text) {
|
|
6
|
+
let res = text;
|
|
7
|
+
// Escape basic HTML characters to prevent breaking layout
|
|
8
|
+
res = res
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>');
|
|
12
|
+
// Bold (**text** or __text__)
|
|
13
|
+
res = res.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
|
14
|
+
res = res.replace(/__(.*?)__/g, '<strong>$1</strong>');
|
|
15
|
+
// Italic (*text* or _text_)
|
|
16
|
+
res = res.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
|
17
|
+
res = res.replace(/_(.*?)_/g, '<em>$1</em>');
|
|
18
|
+
// Inline code (`code`)
|
|
19
|
+
res = res.replace(/`(.*?)`/g, '<code class="inline-code">$1</code>');
|
|
20
|
+
// Keywords -> High-fidelity badges with strict bracket matching to avoid double replacements
|
|
21
|
+
res = res.replace(/\[(Alert|Warning|Caution|Anomaly|Drop|Spike)\]/gi, '<span class="badge badge-warning">$1</span>');
|
|
22
|
+
res = res.replace(/\[(Critical|Error|Failed|High Risk)\]/gi, '<span class="badge badge-danger">$1</span>');
|
|
23
|
+
res = res.replace(/\[(Success|On Track|Active|Passed|Resolved)\]/gi, '<span class="badge badge-success">$1</span>');
|
|
24
|
+
res = res.replace(/\[(Info|Daily|Weekly|Monthly|Report|Recommended|Notice)\]/gi, '<span class="badge badge-info">$1</span>');
|
|
25
|
+
return res;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Builds a beautifully styled HTML table from markdown table rows.
|
|
29
|
+
*/
|
|
30
|
+
function buildTableHtml(rows) {
|
|
31
|
+
if (rows.length < 2)
|
|
32
|
+
return '';
|
|
33
|
+
const headerRow = rows[0];
|
|
34
|
+
const dataRows = rows.slice(1).filter(r => !r.match(/^\|\s*[-|:\s]+\s*\|$/));
|
|
35
|
+
const parseRow = (row) => {
|
|
36
|
+
return row
|
|
37
|
+
.split('|')
|
|
38
|
+
.slice(1, -1)
|
|
39
|
+
.map(cell => cell.trim());
|
|
40
|
+
};
|
|
41
|
+
const headers = parseRow(headerRow);
|
|
42
|
+
const thead = `<thead><tr>${headers.map(h => `<th>${parseInlineStyles(h)}</th>`).join('')}</tr></thead>`;
|
|
43
|
+
const tbody = `<tbody>${dataRows.map(row => {
|
|
44
|
+
const cells = parseRow(row);
|
|
45
|
+
return `<tr>${cells.map(c => `<td>${parseInlineStyles(c)}</td>`).join('')}</tr>`;
|
|
46
|
+
}).join('')}</tbody>`;
|
|
47
|
+
return `<div class="table-container"><table>${thead}${tbody}</table></div>`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Core Markdown-to-HTML parser optimized for OpenAds reports.
|
|
51
|
+
* Uses a robust line-by-line state machine for exact output representation.
|
|
52
|
+
*/
|
|
53
|
+
export function parseMarkdown(md) {
|
|
54
|
+
let html = md.replace(/\r\n/g, '\n');
|
|
55
|
+
// 1. Fenced code blocks
|
|
56
|
+
const codeBlocks = [];
|
|
57
|
+
html = html.replace(/```([\s\S]*?)```/g, (_, code) => {
|
|
58
|
+
const id = `___CODE_BLOCK_${codeBlocks.length}___`;
|
|
59
|
+
const escapedCode = code
|
|
60
|
+
.replace(/&/g, '&')
|
|
61
|
+
.replace(/</g, '<')
|
|
62
|
+
.replace(/>/g, '>');
|
|
63
|
+
codeBlocks.push(`<pre class="code-block"><code>${escapedCode.trim()}</code></pre>`);
|
|
64
|
+
return id;
|
|
65
|
+
});
|
|
66
|
+
// 2. Identify tables
|
|
67
|
+
const tables = [];
|
|
68
|
+
const lines = html.split('\n');
|
|
69
|
+
const linesWithTables = [];
|
|
70
|
+
let inTable = false;
|
|
71
|
+
let tableRows = [];
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
const line = lines[i].trim();
|
|
74
|
+
if (line.startsWith('|') && line.endsWith('|')) {
|
|
75
|
+
inTable = true;
|
|
76
|
+
tableRows.push(line);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
if (inTable) {
|
|
80
|
+
const tableHtml = buildTableHtml(tableRows);
|
|
81
|
+
const id = `___TABLE_${tables.length}___`;
|
|
82
|
+
tables.push(tableHtml);
|
|
83
|
+
linesWithTables.push(id);
|
|
84
|
+
tableRows = [];
|
|
85
|
+
inTable = false;
|
|
86
|
+
}
|
|
87
|
+
linesWithTables.push(lines[i]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (inTable && tableRows.length > 0) {
|
|
91
|
+
const tableHtml = buildTableHtml(tableRows);
|
|
92
|
+
const id = `___TABLE_${tables.length}___`;
|
|
93
|
+
tables.push(tableHtml);
|
|
94
|
+
linesWithTables.push(id);
|
|
95
|
+
}
|
|
96
|
+
// 3. Process line-by-line
|
|
97
|
+
const processedHtml = [];
|
|
98
|
+
let inList = false;
|
|
99
|
+
let isOrdered = false;
|
|
100
|
+
const closeListIfNeeded = () => {
|
|
101
|
+
if (inList) {
|
|
102
|
+
processedHtml.push(isOrdered ? '</ol>' : '</ul>');
|
|
103
|
+
inList = false;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
for (let i = 0; i < linesWithTables.length; i++) {
|
|
107
|
+
const line = linesWithTables[i].trim();
|
|
108
|
+
if (!line) {
|
|
109
|
+
closeListIfNeeded();
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Fenced code blocks / table placeholders
|
|
113
|
+
if (line.startsWith('___CODE_BLOCK_') && line.endsWith('___')) {
|
|
114
|
+
closeListIfNeeded();
|
|
115
|
+
processedHtml.push(line);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (line.startsWith('___TABLE_') && line.endsWith('___')) {
|
|
119
|
+
closeListIfNeeded();
|
|
120
|
+
processedHtml.push(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Headings
|
|
124
|
+
if (line.startsWith('#')) {
|
|
125
|
+
closeListIfNeeded();
|
|
126
|
+
const match = line.match(/^(#{1,6})\s+(.*)$/);
|
|
127
|
+
if (match) {
|
|
128
|
+
const level = match[1].length;
|
|
129
|
+
const text = parseInlineStyles(match[2]);
|
|
130
|
+
if (level === 1) {
|
|
131
|
+
processedHtml.push(`<h1>${text}</h1>`);
|
|
132
|
+
}
|
|
133
|
+
else if (level === 2) {
|
|
134
|
+
processedHtml.push(`</section><section class="report-card"><h2>${text}</h2>`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
processedHtml.push(`<h${level}>${text}</h${level}>`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Horizontal rule / dividers
|
|
143
|
+
if (line === '---' || line === '***') {
|
|
144
|
+
closeListIfNeeded();
|
|
145
|
+
processedHtml.push('<hr class="report-divider">');
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Lists (unordered)
|
|
149
|
+
const ulMatch = line.match(/^[-*]\s+(.*)$/);
|
|
150
|
+
if (ulMatch) {
|
|
151
|
+
if (!inList || isOrdered) {
|
|
152
|
+
closeListIfNeeded();
|
|
153
|
+
processedHtml.push('<ul>');
|
|
154
|
+
inList = true;
|
|
155
|
+
isOrdered = false;
|
|
156
|
+
}
|
|
157
|
+
let content = ulMatch[1];
|
|
158
|
+
const hasChecked = content.startsWith('[x] ');
|
|
159
|
+
const hasUnchecked = content.startsWith('[ ] ');
|
|
160
|
+
if (hasChecked || hasUnchecked) {
|
|
161
|
+
content = content.slice(4);
|
|
162
|
+
}
|
|
163
|
+
// Parse inline styles on text only before wrapping in custom elements
|
|
164
|
+
content = parseInlineStyles(content);
|
|
165
|
+
if (hasChecked) {
|
|
166
|
+
content = `<span class="checkbox checked">ā</span> <span class="checkbox-text">${content}</span>`;
|
|
167
|
+
}
|
|
168
|
+
else if (hasUnchecked) {
|
|
169
|
+
content = `<span class="checkbox unchecked"></span> <span class="checkbox-text">${content}</span>`;
|
|
170
|
+
}
|
|
171
|
+
processedHtml.push(`<li>${content}</li>`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Lists (ordered)
|
|
175
|
+
const olMatch = line.match(/^(\d+)\.\s+(.*)$/);
|
|
176
|
+
if (olMatch) {
|
|
177
|
+
if (!inList || !isOrdered) {
|
|
178
|
+
closeListIfNeeded();
|
|
179
|
+
processedHtml.push('<ol>');
|
|
180
|
+
inList = true;
|
|
181
|
+
isOrdered = true;
|
|
182
|
+
}
|
|
183
|
+
let content = olMatch[2];
|
|
184
|
+
content = parseInlineStyles(content);
|
|
185
|
+
processedHtml.push(`<li>${content}</li>`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// If it's a plain line, it is a paragraph
|
|
189
|
+
closeListIfNeeded();
|
|
190
|
+
processedHtml.push(`<p>${parseInlineStyles(line)}</p>`);
|
|
191
|
+
}
|
|
192
|
+
closeListIfNeeded();
|
|
193
|
+
let finalHtml = processedHtml.join('\n');
|
|
194
|
+
// Clean sections wrap
|
|
195
|
+
finalHtml = '<section class="report-card intro-card">\n' + finalHtml;
|
|
196
|
+
finalHtml += '\n</section>';
|
|
197
|
+
finalHtml = finalHtml.replace(/<section class="report-card intro-card">\s*<\/section>/g, '');
|
|
198
|
+
finalHtml = finalHtml.replace(/<section class="report-card">\s*<\/section>/g, '');
|
|
199
|
+
// 4. Restore placeholders
|
|
200
|
+
codeBlocks.forEach((code, idx) => {
|
|
201
|
+
finalHtml = finalHtml.replace(`___CODE_BLOCK_${idx}___`, code);
|
|
202
|
+
});
|
|
203
|
+
tables.forEach((table, idx) => {
|
|
204
|
+
finalHtml = finalHtml.replace(`___TABLE_${idx}___`, table);
|
|
205
|
+
});
|
|
206
|
+
return finalHtml;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Wraps parsed HTML body in a complete, premium styled HTML page template.
|
|
210
|
+
*/
|
|
211
|
+
export function compileHtmlReport(title, markdownContent) {
|
|
212
|
+
const parsedBody = parseMarkdown(markdownContent);
|
|
213
|
+
const formattedTitle = title.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
214
|
+
return `<!DOCTYPE html>
|
|
215
|
+
<html lang="en">
|
|
216
|
+
<head>
|
|
217
|
+
<meta charset="UTF-8">
|
|
218
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
219
|
+
<title>OpenAds Report ā ${formattedTitle}</title>
|
|
220
|
+
<!-- Modern Premium Typography -->
|
|
221
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
222
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
223
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@500;600;700;800&display=swap" rel="stylesheet">
|
|
224
|
+
|
|
225
|
+
<style>
|
|
226
|
+
/* āāā Premium Modern CSS variables āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
227
|
+
:root {
|
|
228
|
+
--bg-color: #0b0f19;
|
|
229
|
+
--card-bg: rgba(23, 28, 41, 0.6);
|
|
230
|
+
--card-border: rgba(255, 255, 255, 0.08);
|
|
231
|
+
--text-main: #a4b0be;
|
|
232
|
+
--text-bright: #ffffff;
|
|
233
|
+
--text-muted: #747d8c;
|
|
234
|
+
|
|
235
|
+
/* Glowing Gradients */
|
|
236
|
+
--primary-gradient: linear-gradient(135deg, #00d2ff 0%, #3a7bd5 100%);
|
|
237
|
+
--accent-color: #00d2ff;
|
|
238
|
+
|
|
239
|
+
/* Status Colors */
|
|
240
|
+
--color-success: #2ed573;
|
|
241
|
+
--bg-success: rgba(46, 213, 115, 0.15);
|
|
242
|
+
--color-warning: #ffa502;
|
|
243
|
+
--bg-warning: rgba(255, 165, 2, 0.15);
|
|
244
|
+
--color-danger: #ff4757;
|
|
245
|
+
--bg-danger: rgba(255, 71, 87, 0.15);
|
|
246
|
+
--color-info: #00d2ff;
|
|
247
|
+
--bg-info: rgba(0, 210, 255, 0.15);
|
|
248
|
+
|
|
249
|
+
--shadow-premium: 0 12px 40px rgba(0, 0, 0, 0.5);
|
|
250
|
+
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* āāā Global Reset & Styling āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
254
|
+
* {
|
|
255
|
+
box-sizing: border-box;
|
|
256
|
+
margin: 0;
|
|
257
|
+
padding: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
body {
|
|
261
|
+
font-family: 'Inter', sans-serif;
|
|
262
|
+
background-color: var(--bg-color);
|
|
263
|
+
color: var(--text-main);
|
|
264
|
+
line-height: 1.6;
|
|
265
|
+
padding: 3rem 1.5rem;
|
|
266
|
+
min-height: 100vh;
|
|
267
|
+
background-image:
|
|
268
|
+
radial-gradient(circle at 10% 20%, rgba(0, 210, 255, 0.04) 0%, transparent 40%),
|
|
269
|
+
radial-gradient(circle at 90% 80%, rgba(58, 123, 213, 0.04) 0%, transparent 40%);
|
|
270
|
+
background-attachment: fixed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.container {
|
|
274
|
+
max-width: 900px;
|
|
275
|
+
margin: 0 auto;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* āāā Premium Header āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
279
|
+
header {
|
|
280
|
+
margin-bottom: 3.5rem;
|
|
281
|
+
text-align: center;
|
|
282
|
+
position: relative;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.logo-badge {
|
|
286
|
+
display: inline-flex;
|
|
287
|
+
align-items: center;
|
|
288
|
+
gap: 0.5rem;
|
|
289
|
+
background: rgba(255, 255, 255, 0.03);
|
|
290
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
291
|
+
padding: 0.4rem 1rem;
|
|
292
|
+
border-radius: 100px;
|
|
293
|
+
font-family: 'Outfit', sans-serif;
|
|
294
|
+
font-size: 0.85rem;
|
|
295
|
+
letter-spacing: 1px;
|
|
296
|
+
text-transform: uppercase;
|
|
297
|
+
color: var(--accent-color);
|
|
298
|
+
margin-bottom: 1.5rem;
|
|
299
|
+
backdrop-filter: blur(10px);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.logo-badge svg {
|
|
303
|
+
width: 14px;
|
|
304
|
+
height: 14px;
|
|
305
|
+
fill: currentColor;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
header h1 {
|
|
309
|
+
font-family: 'Outfit', sans-serif;
|
|
310
|
+
font-weight: 800;
|
|
311
|
+
font-size: 2.8rem;
|
|
312
|
+
color: var(--text-bright);
|
|
313
|
+
line-height: 1.2;
|
|
314
|
+
background: var(--primary-gradient);
|
|
315
|
+
-webkit-background-clip: text;
|
|
316
|
+
-webkit-text-fill-color: transparent;
|
|
317
|
+
margin-bottom: 0.5rem;
|
|
318
|
+
letter-spacing: -0.5px;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
header .report-subtitle {
|
|
322
|
+
font-size: 1.1rem;
|
|
323
|
+
color: var(--text-muted);
|
|
324
|
+
margin-bottom: 1.5rem;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* āāā Premium Card Layout āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
328
|
+
.report-card {
|
|
329
|
+
background: var(--card-bg);
|
|
330
|
+
border: 1px solid var(--card-border);
|
|
331
|
+
border-radius: 20px;
|
|
332
|
+
padding: 2.5rem;
|
|
333
|
+
margin-bottom: 2.5rem;
|
|
334
|
+
box-shadow: var(--shadow-premium);
|
|
335
|
+
backdrop-filter: blur(12px);
|
|
336
|
+
transition: var(--transition-smooth);
|
|
337
|
+
position: relative;
|
|
338
|
+
overflow: hidden;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.report-card:hover {
|
|
342
|
+
transform: translateY(-4px);
|
|
343
|
+
border-color: rgba(0, 210, 255, 0.2);
|
|
344
|
+
box-shadow: 0 16px 48px rgba(0, 210, 255, 0.08);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.report-card::before {
|
|
348
|
+
content: '';
|
|
349
|
+
position: absolute;
|
|
350
|
+
top: 0;
|
|
351
|
+
left: 0;
|
|
352
|
+
width: 4px;
|
|
353
|
+
height: 100%;
|
|
354
|
+
background: var(--primary-gradient);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Card Typography */
|
|
358
|
+
.report-card h2 {
|
|
359
|
+
font-family: 'Outfit', sans-serif;
|
|
360
|
+
font-weight: 700;
|
|
361
|
+
font-size: 1.6rem;
|
|
362
|
+
color: var(--text-bright);
|
|
363
|
+
margin-bottom: 1.5rem;
|
|
364
|
+
display: flex;
|
|
365
|
+
align-items: center;
|
|
366
|
+
gap: 0.75rem;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.report-card h3 {
|
|
370
|
+
font-family: 'Outfit', sans-serif;
|
|
371
|
+
font-weight: 600;
|
|
372
|
+
font-size: 1.25rem;
|
|
373
|
+
color: var(--text-bright);
|
|
374
|
+
margin: 1.5rem 0 1rem 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.report-card p {
|
|
378
|
+
margin-bottom: 1.2rem;
|
|
379
|
+
font-size: 1.05rem;
|
|
380
|
+
color: var(--text-main);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.report-card p strong {
|
|
384
|
+
color: var(--text-bright);
|
|
385
|
+
font-weight: 600;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* Divider */
|
|
389
|
+
.report-divider {
|
|
390
|
+
border: none;
|
|
391
|
+
height: 1px;
|
|
392
|
+
background: rgba(255, 255, 255, 0.06);
|
|
393
|
+
margin: 2rem 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* āāā Lists & Checkboxes āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
397
|
+
.report-card ul, .report-card ol {
|
|
398
|
+
margin-bottom: 1.5rem;
|
|
399
|
+
padding-left: 0.5rem;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.report-card li {
|
|
403
|
+
margin-bottom: 0.8rem;
|
|
404
|
+
font-size: 1.05rem;
|
|
405
|
+
list-style: none;
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: flex-start;
|
|
408
|
+
gap: 0.75rem;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.report-card ul li::before {
|
|
412
|
+
content: "ā¢";
|
|
413
|
+
color: var(--accent-color);
|
|
414
|
+
font-weight: bold;
|
|
415
|
+
font-size: 1.2rem;
|
|
416
|
+
line-height: 1.4rem;
|
|
417
|
+
display: inline-block;
|
|
418
|
+
flex-shrink: 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.report-card ol {
|
|
422
|
+
list-style-type: decimal;
|
|
423
|
+
padding-left: 1.5rem;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.report-card ol li {
|
|
427
|
+
display: list-item;
|
|
428
|
+
list-style-position: outside;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* Styled Checkboxes */
|
|
432
|
+
.checkbox {
|
|
433
|
+
display: inline-flex;
|
|
434
|
+
align-items: center;
|
|
435
|
+
justify-content: center;
|
|
436
|
+
width: 18px;
|
|
437
|
+
height: 18px;
|
|
438
|
+
border-radius: 4px;
|
|
439
|
+
margin-top: 0.2rem;
|
|
440
|
+
flex-shrink: 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.checkbox.checked {
|
|
444
|
+
background: var(--color-success);
|
|
445
|
+
color: #0b0f19;
|
|
446
|
+
font-size: 0.75rem;
|
|
447
|
+
font-weight: bold;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.checkbox.unchecked {
|
|
451
|
+
border: 2px solid var(--text-muted);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.checkbox-text {
|
|
455
|
+
flex: 1;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* āāā Premium Tables āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
459
|
+
.table-container {
|
|
460
|
+
width: 100%;
|
|
461
|
+
overflow-x: auto;
|
|
462
|
+
margin: 2rem 0;
|
|
463
|
+
border-radius: 12px;
|
|
464
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
465
|
+
background: rgba(0, 0, 0, 0.15);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
table {
|
|
469
|
+
width: 100%;
|
|
470
|
+
border-collapse: collapse;
|
|
471
|
+
text-align: left;
|
|
472
|
+
font-size: 0.95rem;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
th {
|
|
476
|
+
font-family: 'Outfit', sans-serif;
|
|
477
|
+
font-weight: 600;
|
|
478
|
+
color: var(--text-bright);
|
|
479
|
+
background: rgba(255, 255, 255, 0.02);
|
|
480
|
+
padding: 1rem 1.25rem;
|
|
481
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
482
|
+
font-size: 0.85rem;
|
|
483
|
+
text-transform: uppercase;
|
|
484
|
+
letter-spacing: 0.5px;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
td {
|
|
488
|
+
padding: 1rem 1.25rem;
|
|
489
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
|
490
|
+
color: var(--text-main);
|
|
491
|
+
vertical-align: middle;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
tr:last-child td {
|
|
495
|
+
border-bottom: none;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
tr:hover td {
|
|
499
|
+
background: rgba(255, 255, 255, 0.015);
|
|
500
|
+
color: var(--text-bright);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* āāā High-Fidelity Badges āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
504
|
+
.badge {
|
|
505
|
+
display: inline-flex;
|
|
506
|
+
align-items: center;
|
|
507
|
+
padding: 0.25rem 0.65rem;
|
|
508
|
+
border-radius: 100px;
|
|
509
|
+
font-size: 0.75rem;
|
|
510
|
+
font-weight: 600;
|
|
511
|
+
letter-spacing: 0.3px;
|
|
512
|
+
text-transform: uppercase;
|
|
513
|
+
font-family: 'Outfit', sans-serif;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.badge-success {
|
|
517
|
+
background: var(--bg-success);
|
|
518
|
+
color: var(--color-success);
|
|
519
|
+
border: 1px solid rgba(46, 213, 115, 0.3);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.badge-warning {
|
|
523
|
+
background: var(--bg-warning);
|
|
524
|
+
color: var(--color-warning);
|
|
525
|
+
border: 1px solid rgba(255, 165, 2, 0.3);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.badge-danger {
|
|
529
|
+
background: var(--bg-danger);
|
|
530
|
+
color: var(--color-danger);
|
|
531
|
+
border: 1px solid rgba(255, 71, 87, 0.3);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.badge-info {
|
|
535
|
+
background: var(--bg-info);
|
|
536
|
+
color: var(--color-info);
|
|
537
|
+
border: 1px solid rgba(0, 210, 255, 0.3);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/* āāā Code Blocks āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
541
|
+
.code-block {
|
|
542
|
+
background: #060911;
|
|
543
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
544
|
+
border-radius: 12px;
|
|
545
|
+
padding: 1.5rem;
|
|
546
|
+
margin: 1.5rem 0;
|
|
547
|
+
overflow-x: auto;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.code-block code {
|
|
551
|
+
font-family: 'Courier New', Courier, monospace;
|
|
552
|
+
color: #00d2ff;
|
|
553
|
+
font-size: 0.9rem;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.inline-code {
|
|
557
|
+
font-family: 'Courier New', Courier, monospace;
|
|
558
|
+
background: rgba(255, 255, 255, 0.05);
|
|
559
|
+
color: var(--accent-color);
|
|
560
|
+
padding: 0.15rem 0.35rem;
|
|
561
|
+
border-radius: 4px;
|
|
562
|
+
font-size: 0.9em;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* āāā Footer āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
566
|
+
footer {
|
|
567
|
+
text-align: center;
|
|
568
|
+
margin-top: 5rem;
|
|
569
|
+
padding-top: 2rem;
|
|
570
|
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
|
571
|
+
color: var(--text-muted);
|
|
572
|
+
font-size: 0.85rem;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
footer a {
|
|
576
|
+
color: var(--accent-color);
|
|
577
|
+
text-decoration: none;
|
|
578
|
+
transition: var(--transition-smooth);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
footer a:hover {
|
|
582
|
+
text-decoration: underline;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* āāā Responsive Adjustments āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
|
|
586
|
+
@media (max-width: 768px) {
|
|
587
|
+
body {
|
|
588
|
+
padding: 2rem 1rem;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
header h1 {
|
|
592
|
+
font-size: 2.2rem;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.report-card {
|
|
596
|
+
padding: 1.75rem;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
th, td {
|
|
600
|
+
padding: 0.75rem 1rem;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
</style>
|
|
604
|
+
</head>
|
|
605
|
+
<body>
|
|
606
|
+
<div class="container">
|
|
607
|
+
<header>
|
|
608
|
+
<div class="logo-badge">
|
|
609
|
+
<svg viewBox="0 0 24 24">
|
|
610
|
+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
|
|
611
|
+
</svg>
|
|
612
|
+
OpenAds AI
|
|
613
|
+
</div>
|
|
614
|
+
<h1>${formattedTitle}</h1>
|
|
615
|
+
<div class="report-subtitle">Autonomous Campaign Performance Report</div>
|
|
616
|
+
</header>
|
|
617
|
+
|
|
618
|
+
<main>
|
|
619
|
+
${parsedBody}
|
|
620
|
+
</main>
|
|
621
|
+
|
|
622
|
+
<footer>
|
|
623
|
+
<p>Report compiled autonomously by <a href="https://github.com/lamorim-net/openads-ai" target="_blank">OpenAds AI Command Center</a>.</p>
|
|
624
|
+
</footer>
|
|
625
|
+
</div>
|
|
626
|
+
</body>
|
|
627
|
+
</html>`;
|
|
628
|
+
}
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import enquirer from 'enquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
import gradient from 'gradient-string';
|
|
8
|
+
import open from 'open';
|
|
9
|
+
import { compileHtmlReport } from './report-template.js';
|
|
10
|
+
const openadsGradient = gradient(['#00d2ff', '#3a7bd5', '#00d2ff']);
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), '.openads');
|
|
12
|
+
const SCHEDULES_DIR = path.join(CONFIG_DIR, 'schedules');
|
|
13
|
+
const REPORTS_DIR = path.join(CONFIG_DIR, 'reports');
|
|
14
|
+
const PRESETS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'daily-health',
|
|
17
|
+
label: 'š Daily campaign health check',
|
|
18
|
+
prompt: 'Run a health check on all my connected ad campaigns. Flag any anomalies: budget pacing issues, sudden CPA spikes, quality score drops, disapproved ads, or campaigns that spent more than 20% above/below their daily budget. Give me a concise summary with action items.',
|
|
19
|
+
cron: '0 8 * * *',
|
|
20
|
+
description: 'Every day at 8:00 AM',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'budget-pacing',
|
|
24
|
+
label: 'šø Budget pacing alert (every 6 hours)',
|
|
25
|
+
prompt: 'Check my ad campaign spend pacing against daily/monthly budgets. Flag any campaign that is on track to overspend by more than 15% or underspend by more than 25%. Include the current spend, projected spend, and budget for each flagged campaign.',
|
|
26
|
+
cron: '0 */6 * * *',
|
|
27
|
+
description: 'Every 6 hours',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'performance-drop',
|
|
31
|
+
label: 'š Performance drop alert (twice daily)',
|
|
32
|
+
prompt: 'Compare my ad campaign performance (ROAS, CPA, CTR, conversion rate) for the last 24 hours against the 7-day average. Flag any metric that shifted more than 15% in either direction. For each flag, suggest a possible cause and a recommended action.',
|
|
33
|
+
cron: '0 9,17 * * *',
|
|
34
|
+
description: 'At 9:00 AM and 5:00 PM',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'weekly-report',
|
|
38
|
+
label: 'š Weekly performance report (Monday 9am)',
|
|
39
|
+
prompt: 'Generate a comprehensive weekly performance report for all my connected ad campaigns. Include: total spend, ROAS, CPA, impressions, clicks, conversions, top 3 performing campaigns, bottom 3 performing campaigns, and 3 actionable recommendations for next week.',
|
|
40
|
+
cron: '0 9 * * 1',
|
|
41
|
+
description: 'Every Monday at 9:00 AM',
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
// āāā Platform Detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
45
|
+
function isMacOS() {
|
|
46
|
+
return os.platform() === 'darwin';
|
|
47
|
+
}
|
|
48
|
+
function ensureDirs() {
|
|
49
|
+
if (!fs.existsSync(SCHEDULES_DIR))
|
|
50
|
+
fs.mkdirSync(SCHEDULES_DIR, { recursive: true });
|
|
51
|
+
if (!fs.existsSync(REPORTS_DIR))
|
|
52
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
function loadSchedules() {
|
|
55
|
+
ensureDirs();
|
|
56
|
+
const indexPath = path.join(SCHEDULES_DIR, 'schedules.json');
|
|
57
|
+
if (!fs.existsSync(indexPath))
|
|
58
|
+
return [];
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function saveSchedules(schedules) {
|
|
67
|
+
ensureDirs();
|
|
68
|
+
fs.writeFileSync(path.join(SCHEDULES_DIR, 'schedules.json'), JSON.stringify(schedules, null, 2));
|
|
69
|
+
}
|
|
70
|
+
// āāā macOS launchd āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
71
|
+
function cronToLaunchdCalendar(cron) {
|
|
72
|
+
const parts = cron.split(' ');
|
|
73
|
+
const [minute, hour, day, _month, weekday] = parts;
|
|
74
|
+
const cal = {};
|
|
75
|
+
if (minute !== '*') {
|
|
76
|
+
if (minute.startsWith('*/')) {
|
|
77
|
+
cal.Minute = parseInt(minute.slice(2));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
cal.Minute = parseInt(minute);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (hour !== '*') {
|
|
84
|
+
if (hour.startsWith('*/')) {
|
|
85
|
+
// launchd doesn't support */N for hours directly, use Interval instead
|
|
86
|
+
}
|
|
87
|
+
else if (hour.includes(',')) {
|
|
88
|
+
// Multiple hours ā return array of calendars
|
|
89
|
+
return hour.split(',').map((h) => ({
|
|
90
|
+
...cal,
|
|
91
|
+
Hour: parseInt(h),
|
|
92
|
+
Minute: cal.Minute ?? 0,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
cal.Hour = parseInt(hour);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (day !== '*')
|
|
100
|
+
cal.Day = parseInt(day);
|
|
101
|
+
if (weekday !== '*')
|
|
102
|
+
cal.Weekday = parseInt(weekday);
|
|
103
|
+
return cal;
|
|
104
|
+
}
|
|
105
|
+
function installLaunchd(schedule, openadsPath) {
|
|
106
|
+
const label = `com.openads.schedule.${schedule.name}`;
|
|
107
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
108
|
+
const reportFile = path.join(REPORTS_DIR, `${schedule.name}-latest.md`);
|
|
109
|
+
const calendar = cronToLaunchdCalendar(schedule.cron);
|
|
110
|
+
const calendarEntries = Array.isArray(calendar) ? calendar : [calendar];
|
|
111
|
+
// Check if */N hour pattern ā use StartInterval instead
|
|
112
|
+
const parts = schedule.cron.split(' ');
|
|
113
|
+
const hourPart = parts[1];
|
|
114
|
+
let useInterval = false;
|
|
115
|
+
let intervalSeconds = 0;
|
|
116
|
+
if (hourPart.startsWith('*/')) {
|
|
117
|
+
useInterval = true;
|
|
118
|
+
intervalSeconds = parseInt(hourPart.slice(2)) * 3600;
|
|
119
|
+
}
|
|
120
|
+
let schedulingXml;
|
|
121
|
+
if (useInterval) {
|
|
122
|
+
schedulingXml = ` <key>StartInterval</key>\n <integer>${intervalSeconds}</integer>`;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const calXml = calendarEntries.map((cal) => {
|
|
126
|
+
let entries = '';
|
|
127
|
+
if (cal.Minute !== undefined)
|
|
128
|
+
entries += ` <key>Minute</key>\n <integer>${cal.Minute}</integer>\n`;
|
|
129
|
+
if (cal.Hour !== undefined)
|
|
130
|
+
entries += ` <key>Hour</key>\n <integer>${cal.Hour}</integer>\n`;
|
|
131
|
+
if (cal.Day !== undefined)
|
|
132
|
+
entries += ` <key>Day</key>\n <integer>${cal.Day}</integer>\n`;
|
|
133
|
+
if (cal.Weekday !== undefined)
|
|
134
|
+
entries += ` <key>Weekday</key>\n <integer>${cal.Weekday}</integer>\n`;
|
|
135
|
+
return ` <dict>\n${entries} </dict>`;
|
|
136
|
+
}).join('\n');
|
|
137
|
+
schedulingXml = ` <key>StartCalendarInterval</key>\n <array>\n${calXml}\n </array>`;
|
|
138
|
+
}
|
|
139
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
140
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
141
|
+
<plist version="1.0">
|
|
142
|
+
<dict>
|
|
143
|
+
<key>Label</key>
|
|
144
|
+
<string>${label}</string>
|
|
145
|
+
<key>ProgramArguments</key>
|
|
146
|
+
<array>
|
|
147
|
+
<string>${openadsPath}</string>
|
|
148
|
+
<string>run-schedule</string>
|
|
149
|
+
<string>${schedule.name}</string>
|
|
150
|
+
</array>
|
|
151
|
+
${schedulingXml}
|
|
152
|
+
<key>StandardOutPath</key>
|
|
153
|
+
<string>${reportFile}</string>
|
|
154
|
+
<key>StandardErrorPath</key>
|
|
155
|
+
<string>${path.join(REPORTS_DIR, `${schedule.name}-error.log`)}</string>
|
|
156
|
+
<key>RunAtLoad</key>
|
|
157
|
+
<false/>
|
|
158
|
+
<key>EnvironmentVariables</key>
|
|
159
|
+
<dict>
|
|
160
|
+
<key>PATH</key>
|
|
161
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
162
|
+
</dict>
|
|
163
|
+
</dict>
|
|
164
|
+
</plist>`;
|
|
165
|
+
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
166
|
+
if (!fs.existsSync(agentsDir))
|
|
167
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(plistPath, plist);
|
|
169
|
+
// Unload if already loaded, then load
|
|
170
|
+
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
|
|
171
|
+
const result = spawnSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, plistPath]);
|
|
172
|
+
return result.status === 0;
|
|
173
|
+
}
|
|
174
|
+
function uninstallLaunchd(name) {
|
|
175
|
+
const label = `com.openads.schedule.${name}`;
|
|
176
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
177
|
+
if (fs.existsSync(plistPath)) {
|
|
178
|
+
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plistPath], { stdio: 'ignore' });
|
|
179
|
+
fs.unlinkSync(plistPath);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// āāā Linux/Generic crontab āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
183
|
+
function installCrontab(schedule, openadsPath) {
|
|
184
|
+
const marker = `# openads:${schedule.name}`;
|
|
185
|
+
const reportFile = path.join(REPORTS_DIR, `${schedule.name}-latest.md`);
|
|
186
|
+
const cronLine = `${schedule.cron} ${openadsPath} run-schedule ${schedule.name} > ${reportFile} 2>&1 ${marker}`;
|
|
187
|
+
// Read current crontab
|
|
188
|
+
const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
189
|
+
let lines = (current.stdout || '').split('\n').filter((l) => !l.includes(marker));
|
|
190
|
+
lines.push(cronLine);
|
|
191
|
+
// Write back
|
|
192
|
+
const result = spawnSync('crontab', ['-'], {
|
|
193
|
+
input: lines.join('\n') + '\n',
|
|
194
|
+
encoding: 'utf8',
|
|
195
|
+
});
|
|
196
|
+
return result.status === 0;
|
|
197
|
+
}
|
|
198
|
+
function uninstallCrontab(name) {
|
|
199
|
+
const marker = `# openads:${name}`;
|
|
200
|
+
const current = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
201
|
+
const lines = (current.stdout || '').split('\n').filter((l) => !l.includes(marker));
|
|
202
|
+
spawnSync('crontab', ['-'], {
|
|
203
|
+
input: lines.join('\n') + '\n',
|
|
204
|
+
encoding: 'utf8',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// āāā Run a Scheduled Task āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
208
|
+
export async function runScheduledTask(name) {
|
|
209
|
+
const schedules = loadSchedules();
|
|
210
|
+
const schedule = schedules.find(s => s.name === name);
|
|
211
|
+
if (!schedule) {
|
|
212
|
+
console.error(`Schedule "${name}" not found.`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
// Load config for API key and model
|
|
216
|
+
const configPath = path.join(CONFIG_DIR, 'openads.config.json');
|
|
217
|
+
if (!fs.existsSync(configPath)) {
|
|
218
|
+
console.error('OpenAds is not configured. Run `openads setup` first.');
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
222
|
+
// Print header and accumulate markdown
|
|
223
|
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
224
|
+
let fullMarkdown = '';
|
|
225
|
+
fullMarkdown += `# OpenAds Scheduled Report: ${schedule.name}\n`;
|
|
226
|
+
fullMarkdown += `_Generated: ${now}_\n\n`;
|
|
227
|
+
fullMarkdown += `**Prompt:** ${schedule.prompt}\n\n`;
|
|
228
|
+
fullMarkdown += '---\n\n';
|
|
229
|
+
console.log(fullMarkdown.trim() + '\n');
|
|
230
|
+
// Find the pi CLI
|
|
231
|
+
const pkgDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
232
|
+
const piCliPath = path.resolve(pkgDir, 'node_modules', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js');
|
|
233
|
+
// Build environment
|
|
234
|
+
const env = { ...process.env, NODE_NO_WARNINGS: '1' };
|
|
235
|
+
if (config.apiKey && config.apiKey !== 'dummy-key') {
|
|
236
|
+
if (config.provider.startsWith('google/'))
|
|
237
|
+
env.GOOGLE_API_KEY = config.apiKey;
|
|
238
|
+
else if (config.provider.startsWith('openai/'))
|
|
239
|
+
env.OPENAI_API_KEY = config.apiKey;
|
|
240
|
+
else if (config.provider.startsWith('anthropic/'))
|
|
241
|
+
env.ANTHROPIC_API_KEY = config.apiKey;
|
|
242
|
+
else
|
|
243
|
+
env.OPENAI_API_KEY = config.apiKey;
|
|
244
|
+
}
|
|
245
|
+
if (config.localBaseUrl)
|
|
246
|
+
env.OPENAI_BASE_URL = config.localBaseUrl;
|
|
247
|
+
const skillsDir = path.resolve(pkgDir, 'skills');
|
|
248
|
+
const contextDir = path.join(CONFIG_DIR, 'context');
|
|
249
|
+
const args = [
|
|
250
|
+
piCliPath,
|
|
251
|
+
'--model', config.provider,
|
|
252
|
+
'--skill', skillsDir,
|
|
253
|
+
...(fs.existsSync(contextDir) ? ['--skill', contextDir] : []),
|
|
254
|
+
'--print',
|
|
255
|
+
schedule.prompt,
|
|
256
|
+
];
|
|
257
|
+
const result = spawnSync('node', args, {
|
|
258
|
+
env,
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
timeout: 300000, // 5 minute timeout
|
|
261
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
262
|
+
});
|
|
263
|
+
if (result.stdout) {
|
|
264
|
+
console.log(result.stdout);
|
|
265
|
+
fullMarkdown += result.stdout;
|
|
266
|
+
}
|
|
267
|
+
if (result.stderr) {
|
|
268
|
+
console.error(result.stderr);
|
|
269
|
+
}
|
|
270
|
+
// Compile and save the HTML report
|
|
271
|
+
try {
|
|
272
|
+
const reportHtmlFile = path.join(REPORTS_DIR, `${schedule.name}-latest.html`);
|
|
273
|
+
const htmlContent = compileHtmlReport(schedule.name, fullMarkdown);
|
|
274
|
+
fs.writeFileSync(reportHtmlFile, htmlContent, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
console.error(`Failed to generate HTML report: ${err.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// āāā Interactive Schedule Setup āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
281
|
+
export async function runScheduleManager(subcommand) {
|
|
282
|
+
// Handle sub-commands
|
|
283
|
+
if (subcommand === 'list') {
|
|
284
|
+
return listSchedules();
|
|
285
|
+
}
|
|
286
|
+
if (subcommand === 'remove') {
|
|
287
|
+
return removeSchedule();
|
|
288
|
+
}
|
|
289
|
+
console.log(openadsGradient('\n OpenAds Scheduler ā°\n'));
|
|
290
|
+
console.log(chalk.gray(' Automate campaign checks, reports, and alerts.\n'));
|
|
291
|
+
const presetChoices = PRESETS.map(p => ({
|
|
292
|
+
name: p.name,
|
|
293
|
+
message: `${p.label} ${chalk.gray(`(${p.description})`)}`,
|
|
294
|
+
}));
|
|
295
|
+
presetChoices.push({
|
|
296
|
+
name: 'custom',
|
|
297
|
+
message: `${chalk.cyan('ā°')} Custom schedule (describe in plain English)`,
|
|
298
|
+
});
|
|
299
|
+
presetChoices.push({
|
|
300
|
+
name: 'list',
|
|
301
|
+
message: `${chalk.gray('š')} View active schedules`,
|
|
302
|
+
});
|
|
303
|
+
presetChoices.push({
|
|
304
|
+
name: 'remove',
|
|
305
|
+
message: `${chalk.gray('šļø')} Remove a schedule`,
|
|
306
|
+
});
|
|
307
|
+
presetChoices.push({
|
|
308
|
+
name: 'view-reports',
|
|
309
|
+
message: `${chalk.green('š')} View latest HTML reports in browser`,
|
|
310
|
+
});
|
|
311
|
+
const { action } = await enquirer.prompt({
|
|
312
|
+
type: 'select',
|
|
313
|
+
name: 'action',
|
|
314
|
+
message: chalk.bold('What would you like to automate?'),
|
|
315
|
+
choices: presetChoices,
|
|
316
|
+
});
|
|
317
|
+
if (action === 'list')
|
|
318
|
+
return listSchedules();
|
|
319
|
+
if (action === 'remove')
|
|
320
|
+
return removeSchedule();
|
|
321
|
+
if (action === 'view-reports')
|
|
322
|
+
return chooseAndOpenReport();
|
|
323
|
+
let schedule;
|
|
324
|
+
if (action === 'custom') {
|
|
325
|
+
const answers = await enquirer.prompt([
|
|
326
|
+
{
|
|
327
|
+
type: 'input',
|
|
328
|
+
name: 'prompt',
|
|
329
|
+
message: 'What should OpenAds check or report on?',
|
|
330
|
+
validate: (v) => v.trim() ? true : 'Please describe what to automate.',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
type: 'select',
|
|
334
|
+
name: 'cron',
|
|
335
|
+
message: 'How often?',
|
|
336
|
+
choices: [
|
|
337
|
+
{ name: '0 8 * * *', message: 'Every day at 8:00 AM' },
|
|
338
|
+
{ name: '0 */6 * * *', message: 'Every 6 hours' },
|
|
339
|
+
{ name: '0 9,17 * * *', message: 'Twice daily (9 AM & 5 PM)' },
|
|
340
|
+
{ name: '0 9 * * 1', message: 'Weekly (Monday 9 AM)' },
|
|
341
|
+
{ name: '0 9 1 * *', message: 'Monthly (1st at 9 AM)' },
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
]);
|
|
345
|
+
const safeName = 'custom-' + Date.now();
|
|
346
|
+
const cronDesc = {
|
|
347
|
+
'0 8 * * *': 'Every day at 8:00 AM',
|
|
348
|
+
'0 */6 * * *': 'Every 6 hours',
|
|
349
|
+
'0 9,17 * * *': 'Twice daily (9 AM & 5 PM)',
|
|
350
|
+
'0 9 * * 1': 'Weekly (Monday 9 AM)',
|
|
351
|
+
'0 9 1 * *': 'Monthly (1st at 9 AM)',
|
|
352
|
+
}[answers.cron] || answers.cron;
|
|
353
|
+
schedule = {
|
|
354
|
+
name: safeName,
|
|
355
|
+
prompt: answers.prompt,
|
|
356
|
+
cron: answers.cron,
|
|
357
|
+
description: cronDesc,
|
|
358
|
+
createdAt: new Date().toISOString(),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
const preset = PRESETS.find(p => p.name === action);
|
|
363
|
+
schedule = {
|
|
364
|
+
name: preset.name,
|
|
365
|
+
prompt: preset.prompt,
|
|
366
|
+
cron: preset.cron,
|
|
367
|
+
description: preset.description,
|
|
368
|
+
createdAt: new Date().toISOString(),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// Find openads executable
|
|
372
|
+
const openadsPath = process.argv[1];
|
|
373
|
+
// Install the schedule
|
|
374
|
+
console.log('');
|
|
375
|
+
let installed = false;
|
|
376
|
+
if (isMacOS()) {
|
|
377
|
+
console.log(chalk.cyan('Installing schedule via macOS launchd...'));
|
|
378
|
+
installed = installLaunchd(schedule, openadsPath);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
console.log(chalk.cyan('Installing schedule via crontab...'));
|
|
382
|
+
installed = installCrontab(schedule, openadsPath);
|
|
383
|
+
}
|
|
384
|
+
if (installed) {
|
|
385
|
+
// Save to our index
|
|
386
|
+
const schedules = loadSchedules().filter(s => s.name !== schedule.name);
|
|
387
|
+
schedules.push(schedule);
|
|
388
|
+
saveSchedules(schedules);
|
|
389
|
+
console.log(chalk.green(`\nā Schedule "${schedule.name}" installed!`));
|
|
390
|
+
console.log(chalk.gray(` Frequency: ${schedule.description}`));
|
|
391
|
+
console.log(chalk.gray(` Reports saved to: ${REPORTS_DIR}`));
|
|
392
|
+
console.log(chalk.gray(`\n Manage with: openads schedule list | openads schedule remove\n`));
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
console.log(chalk.red('\nā Failed to install schedule. Check permissions and try again.\n'));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// āāā List / Remove āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
399
|
+
function listSchedules() {
|
|
400
|
+
const schedules = loadSchedules();
|
|
401
|
+
if (schedules.length === 0) {
|
|
402
|
+
console.log(chalk.yellow('\n No active schedules. Run `openads schedule` to create one.\n'));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
console.log(chalk.bold.cyan('\n Active Schedules'));
|
|
406
|
+
console.log(chalk.gray(' āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n'));
|
|
407
|
+
for (const s of schedules) {
|
|
408
|
+
console.log(` ${chalk.cyan(s.name.padEnd(25))} ${chalk.white(s.description)}`);
|
|
409
|
+
console.log(` ${' '.repeat(25)} ${chalk.gray(s.prompt.slice(0, 80))}${s.prompt.length > 80 ? '...' : ''}`);
|
|
410
|
+
console.log('');
|
|
411
|
+
}
|
|
412
|
+
console.log(chalk.gray(` Reports saved to: ${REPORTS_DIR}\n`));
|
|
413
|
+
}
|
|
414
|
+
async function removeSchedule() {
|
|
415
|
+
const schedules = loadSchedules();
|
|
416
|
+
if (schedules.length === 0) {
|
|
417
|
+
console.log(chalk.yellow('\n No active schedules to remove.\n'));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const choices = schedules.map(s => ({
|
|
421
|
+
name: s.name,
|
|
422
|
+
message: `${s.name} ā ${s.description}`,
|
|
423
|
+
}));
|
|
424
|
+
const { name } = await enquirer.prompt({
|
|
425
|
+
type: 'select',
|
|
426
|
+
name: 'name',
|
|
427
|
+
message: 'Which schedule do you want to remove?',
|
|
428
|
+
choices,
|
|
429
|
+
});
|
|
430
|
+
// Uninstall from OS
|
|
431
|
+
if (isMacOS()) {
|
|
432
|
+
uninstallLaunchd(name);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
uninstallCrontab(name);
|
|
436
|
+
}
|
|
437
|
+
// Remove from index
|
|
438
|
+
const updated = schedules.filter(s => s.name !== name);
|
|
439
|
+
saveSchedules(updated);
|
|
440
|
+
console.log(chalk.green(`\nā Schedule "${name}" removed.\n`));
|
|
441
|
+
}
|
|
442
|
+
export async function openReportInBrowser(name) {
|
|
443
|
+
const reportHtmlFile = path.join(REPORTS_DIR, `${name}-latest.html`);
|
|
444
|
+
if (!fs.existsSync(reportHtmlFile)) {
|
|
445
|
+
// If HTML doesn't exist but MD does, let's compile it on the fly!
|
|
446
|
+
const reportMdFile = path.join(REPORTS_DIR, `${name}-latest.md`);
|
|
447
|
+
if (fs.existsSync(reportMdFile)) {
|
|
448
|
+
const mdContent = fs.readFileSync(reportMdFile, 'utf8');
|
|
449
|
+
const htmlContent = compileHtmlReport(name, mdContent);
|
|
450
|
+
fs.writeFileSync(reportHtmlFile, htmlContent, 'utf8');
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
console.error(chalk.red(`\nā Report for "${name}" not found. Run the schedule first or select another report.\n`));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
console.log(chalk.cyan(`\nOpening HTML report for "${name}" in your default browser...`));
|
|
458
|
+
await open(reportHtmlFile);
|
|
459
|
+
}
|
|
460
|
+
async function chooseAndOpenReport() {
|
|
461
|
+
const schedules = loadSchedules();
|
|
462
|
+
if (schedules.length === 0) {
|
|
463
|
+
console.log(chalk.yellow('\n No active schedules. Run `openads schedule` to create one.\n'));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Find all schedules that have reports
|
|
467
|
+
const choices = schedules.map(s => {
|
|
468
|
+
const reportMdFile = path.join(REPORTS_DIR, `${s.name}-latest.md`);
|
|
469
|
+
const hasReport = fs.existsSync(reportMdFile);
|
|
470
|
+
return {
|
|
471
|
+
name: s.name,
|
|
472
|
+
message: `${s.name} ${chalk.gray(`(${s.description})`)} ${hasReport ? chalk.green('[Report Available]') : chalk.red('[No Report Yet]')}`,
|
|
473
|
+
disabled: !hasReport,
|
|
474
|
+
};
|
|
475
|
+
});
|
|
476
|
+
if (choices.every(c => c.disabled)) {
|
|
477
|
+
console.log(chalk.yellow('\n No reports have been generated yet. Please wait for schedules to run.\n'));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const { name } = await enquirer.prompt({
|
|
481
|
+
type: 'select',
|
|
482
|
+
name: 'name',
|
|
483
|
+
message: 'Select a report to open:',
|
|
484
|
+
choices: choices.filter(c => !c.disabled),
|
|
485
|
+
});
|
|
486
|
+
await openReportInBrowser(name);
|
|
487
|
+
}
|
|
488
|
+
export function listReports() {
|
|
489
|
+
ensureDirs();
|
|
490
|
+
const files = fs.readdirSync(REPORTS_DIR);
|
|
491
|
+
const reports = files.filter(f => f.endsWith('-latest.md'));
|
|
492
|
+
if (reports.length === 0) {
|
|
493
|
+
console.log(chalk.yellow('\n No reports found in your reports directory. Please wait for schedules to run.\n'));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
console.log(chalk.bold.cyan('\n Generated Reports'));
|
|
497
|
+
console.log(chalk.gray(' āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'));
|
|
498
|
+
for (const file of reports) {
|
|
499
|
+
const filePath = path.join(REPORTS_DIR, file);
|
|
500
|
+
const stats = fs.statSync(filePath);
|
|
501
|
+
const name = file.replace('-latest.md', '');
|
|
502
|
+
const date = stats.mtime.toISOString().slice(0, 19).replace('T', ' ');
|
|
503
|
+
const sizeKb = (stats.size / 1024).toFixed(1);
|
|
504
|
+
// Check if HTML version exists
|
|
505
|
+
const htmlExists = fs.existsSync(path.join(REPORTS_DIR, `${name}-latest.html`));
|
|
506
|
+
const formatSupport = htmlExists ? chalk.green('MD + HTML') : chalk.yellow('MD Only');
|
|
507
|
+
console.log(` ${chalk.cyan(name.padEnd(25))} ${chalk.white(`${sizeKb} KB`)} ${chalk.gray(date)} ${formatSupport}`);
|
|
508
|
+
}
|
|
509
|
+
console.log(chalk.gray(`\n Reports saved to: ${REPORTS_DIR}`));
|
|
510
|
+
console.log(chalk.gray(` To view a report in your browser, run: ${chalk.white('openads report [name]')}\n`));
|
|
511
|
+
}
|
package/package.json
CHANGED