@oppira/cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -110
- package/package.json +1 -1
- package/src/commands/ads.js +130 -0
- package/src/commands/discover.js +49 -7
- package/src/index.js +3 -3
- package/src/local.js +3 -3
- package/src/commands/scrape.js +0 -93
package/README.md
CHANGED
|
@@ -1,147 +1,98 @@
|
|
|
1
|
-
# Oppira CLI
|
|
1
|
+
# Oppira CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Drive **Oppira** from your terminal — competitor intelligence, your marketing
|
|
4
|
+
playbook, battlecards, the content studio, and the Oppira AI agent — with
|
|
5
|
+
scriptable, JSON-friendly output.
|
|
5
6
|
|
|
6
|
-
##
|
|
7
|
+
## Requirements
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|------|-----|--------|------|
|
|
10
|
-
| **Remote** (default) | HTTP client against the deployed API | customers, agents, CI | JWT login, or durable `ca_` API key |
|
|
11
|
-
| **Local / ops** | Imports the backend engines + Mongo directly | the team | backend `.env` (MONGO_URI + secrets) |
|
|
12
|
-
|
|
13
|
-
Local mode reaches the **cron-only generators** (insights / alerts / battlecard) and
|
|
14
|
-
the **intelligent scraper** without needing new HTTP endpoints — it mirrors the
|
|
15
|
-
existing one-off scripts in `../scripts` (dotenv + `mongoose.connect` + engine call).
|
|
16
|
-
It only works from a repo checkout with the backend secrets present.
|
|
9
|
+
- **Node.js 20+**
|
|
17
10
|
|
|
18
11
|
## Install
|
|
19
12
|
|
|
20
|
-
**For end users (published package):**
|
|
21
|
-
|
|
22
13
|
```bash
|
|
23
14
|
npm install -g @oppira/cli
|
|
24
15
|
oppira --help
|
|
25
16
|
```
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
cd cli && npm link # then `oppira` is on your PATH
|
|
31
|
-
# or run directly:
|
|
32
|
-
node cli/bin/oppira.js --help
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Only the repo checkout can run the local/ops commands (they need the backend
|
|
36
|
-
`.env` + `node_modules`). The published package runs the **remote** commands.
|
|
37
|
-
|
|
38
|
-
## First run
|
|
18
|
+
## Quick start
|
|
39
19
|
|
|
40
20
|
```bash
|
|
41
|
-
oppira config set-url https://api.oppira.com
|
|
42
|
-
oppira auth login --email you@
|
|
43
|
-
oppira comp list
|
|
21
|
+
oppira config set-url https://api.oppira.com # your Oppira API endpoint
|
|
22
|
+
oppira auth login --email you@company.com # log in
|
|
23
|
+
oppira comp list # see your tracked competitors
|
|
24
|
+
oppira ask "how did my top competitor shift this month?"
|
|
44
25
|
```
|
|
45
26
|
|
|
46
|
-
|
|
27
|
+
Config is stored in `~/.oppira/` (credentials are kept in a separate, locked-down
|
|
28
|
+
file). You can run against several accounts/environments with **profiles**
|
|
29
|
+
(`oppira config use <name>` or `--profile <name>`).
|
|
47
30
|
|
|
48
|
-
|
|
31
|
+
## Authentication
|
|
49
32
|
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
oppira auth login --email you@co.com
|
|
53
|
-
```
|
|
54
|
-
Stores a JWT + refresh token; auto-refreshes. Best for interactive use.
|
|
33
|
+
Pick either method per profile:
|
|
55
34
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
> for REST commands.
|
|
35
|
+
- **Login (interactive):**
|
|
36
|
+
```bash
|
|
37
|
+
oppira auth login --email you@company.com
|
|
38
|
+
```
|
|
39
|
+
- **API key (best for scripts / CI):** create a key in the Oppira web app, then:
|
|
40
|
+
```bash
|
|
41
|
+
oppira auth key set ca_xxxxxxxx
|
|
42
|
+
```
|
|
65
43
|
|
|
66
|
-
|
|
67
|
-
`~/.oppira/credentials.json` (chmod 600). Multiple profiles are supported
|
|
68
|
-
(`oppira config use <name>`, `--profile <name>`, or `OPPIRA_PROFILE`).
|
|
44
|
+
`oppira auth whoami` shows the current account; `oppira auth logout` clears it.
|
|
69
45
|
|
|
70
46
|
## Commands
|
|
71
47
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
comp
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
studio
|
|
83
|
-
artifact
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
48
|
+
| Group | What you can do |
|
|
49
|
+
|-------|-----------------|
|
|
50
|
+
| `oppira ask "…"` | Ask the Oppira AI agent (streams the answer). Reads args or stdin. |
|
|
51
|
+
| `oppira comp` | `list`, `show`, `add`, `track`, `toggle-tracking`, `insights`, `alerts`, `rm` |
|
|
52
|
+
| `oppira insights` | `list`, `summary`, `generate` |
|
|
53
|
+
| `oppira alerts` | `list`, `run` |
|
|
54
|
+
| `oppira battlecard` | `show`, `history`, `regen` |
|
|
55
|
+
| `oppira playbook` | `show`, `bootstrap`, `refresh-section`, `refresh-cluster` |
|
|
56
|
+
| `oppira ads` | `accounts`, `performance`, `campaigns` — your own connected ad accounts *(Pro)* |
|
|
57
|
+
| `oppira discover` | `profiles`, `suggestions`, `dismiss` |
|
|
58
|
+
| `oppira studio` | posts, calendars, comments, templates, image generation, publishing *(Pro)* |
|
|
59
|
+
| `oppira artifact` | `get`, `export` (reports / decks / diagrams) |
|
|
60
|
+
| `oppira config` / `oppira auth` | profiles, API URL, login, API keys |
|
|
61
|
+
|
|
62
|
+
Run `oppira <group> --help` for a group's commands, or `oppira <group> <command> --help` for details.
|
|
87
63
|
|
|
88
|
-
|
|
89
|
-
|
|
64
|
+
Feature access follows your Oppira plan (e.g. the AI agent, playbook and
|
|
65
|
+
battlecards are available from **Basic** up; **Studio** is **Pro**). If a command
|
|
66
|
+
isn't available on your plan you'll get a clear upgrade message.
|
|
90
67
|
|
|
91
|
-
|
|
68
|
+
## Examples
|
|
92
69
|
|
|
93
70
|
```bash
|
|
94
|
-
|
|
71
|
+
# Ask the agent, pipe the answer into your own tooling
|
|
95
72
|
oppira ask "summarize my playbook" --json | jq -r .text
|
|
96
|
-
oppira scrape run Acme --platforms fb,ig # ops, local mode
|
|
97
|
-
oppira insights generate --email client@co.com # force cron-only generator
|
|
98
|
-
oppira battlecard regen Globex
|
|
99
|
-
oppira admin errors --limit 20
|
|
100
|
-
oppira admin ai-usage --by-plan
|
|
101
|
-
```
|
|
102
73
|
|
|
103
|
-
|
|
74
|
+
# Competitors
|
|
75
|
+
oppira comp show Acme
|
|
76
|
+
oppira comp add "Globex" --website globex.com --instagram https://instagram.com/globex
|
|
104
77
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
set on the server:
|
|
78
|
+
# Refresh strategic docs on demand
|
|
79
|
+
oppira insights generate
|
|
80
|
+
oppira battlecard regen Acme
|
|
109
81
|
|
|
110
|
-
|
|
111
|
-
|
|
82
|
+
# Export a generated report
|
|
83
|
+
oppira artifact export <id> --format pdf
|
|
112
84
|
```
|
|
113
85
|
|
|
114
|
-
|
|
86
|
+
## Scripting & automation
|
|
115
87
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
88
|
+
- Add `--json` to any command for machine-readable output.
|
|
89
|
+
- Commands exit non-zero on failure, so they behave well in CI.
|
|
90
|
+
- `oppira ask` reads a question from arguments **or** piped stdin.
|
|
119
91
|
|
|
120
92
|
```bash
|
|
121
|
-
|
|
122
|
-
npm version patch # or minor/major — bump the version
|
|
123
|
-
npm publish # publishes @oppira/cli to npm
|
|
93
|
+
echo "what should I post about this week?" | oppira ask --json
|
|
124
94
|
```
|
|
125
95
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
is unchanged) and drop `publishConfig`.
|
|
130
|
-
- `npm login` first; the publish is public.
|
|
131
|
-
- Only `bin/`, `src/`, `README.md` ship (the `files` field) — no repo internals.
|
|
132
|
-
- End users then: `npm install -g @oppira/cli`.
|
|
133
|
-
|
|
134
|
-
## Status (phased build)
|
|
135
|
-
|
|
136
|
-
- **Working now:** auth/config, competitors (+ analytics: timeline/compare/top-posts/tone in
|
|
137
|
-
local mode), discover, playbook, insights/alerts/battlecard reads, `ask` (SSE), artifact
|
|
138
|
-
get/export, admin (reads + user mgmt + `script` runner folding `../scripts`), studio
|
|
139
|
-
(posts/calendars/comments/templates/formats/settings/channels/assets/policy + actions),
|
|
140
|
-
local/ops force-generators + intelligent scrape.
|
|
141
|
-
- **Remote force-generate:** `insights generate`, `alerts run`, `battlecard regen` now hit
|
|
142
|
-
owner-scoped REST endpoints by default (added to the backend); pass `--local` to run the
|
|
143
|
-
engine directly instead.
|
|
144
|
-
- **Follow-up (still open):** MCP-backed analytics over HTTP (so timeline/compare/top-N work
|
|
145
|
-
remotely, not just local), deeper Studio (image-layer editing, ads), and per-endpoint
|
|
146
|
-
rate-limiting on the force-generate routes.
|
|
147
|
-
```
|
|
96
|
+
## Support
|
|
97
|
+
|
|
98
|
+
Questions or issues? Reach the Oppira team at [oppira.com](https://oppira.com).
|
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// ads: read the user's OWN connected ad-account data (Meta / Google / TikTok /
|
|
2
|
+
// etc., via the Studio ads integration) — NOT competitor ads (those live under
|
|
3
|
+
// `comp` / the get_competitor_* MCP tools). All REST (JWT), gated on the backend
|
|
4
|
+
// by requireStudioEnabled (Pro/admin) + quota. Degrades to an empty / hasAds:false
|
|
5
|
+
// result when no ad account is connected, so the commands never hard-error.
|
|
6
|
+
import { apiRequest } from '../http.js';
|
|
7
|
+
import { out, kv, table, info } from '../output.js';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
description: 'Own ad accounts — connected accounts, performance, campaigns (Pro/admin).',
|
|
11
|
+
commands: {
|
|
12
|
+
accounts: {
|
|
13
|
+
description: 'List your connected ad accounts.',
|
|
14
|
+
usage: 'ads accounts',
|
|
15
|
+
async handler(ctx) {
|
|
16
|
+
const res = await apiRequest(ctx.profileName, 'GET', '/studio/ads-channels');
|
|
17
|
+
const accounts = res?.accounts || [];
|
|
18
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
19
|
+
if (accounts.length === 0) { info('(no ad accounts connected — connect one in Studio → Ads)'); return; }
|
|
20
|
+
const rows = accounts.map((a) => ({
|
|
21
|
+
platform: a.platform || '—',
|
|
22
|
+
handle: a.handle || a.displayName || '—',
|
|
23
|
+
status: a.status || '—',
|
|
24
|
+
accountId: String(a.accountId || '—'),
|
|
25
|
+
}));
|
|
26
|
+
table(rows, [
|
|
27
|
+
{ key: 'platform', label: 'PLATFORM' },
|
|
28
|
+
{ key: 'handle', label: 'HANDLE' },
|
|
29
|
+
{ key: 'status', label: 'STATUS' },
|
|
30
|
+
{ key: 'accountId', label: 'ACCOUNT ID' },
|
|
31
|
+
], ctx);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
performance: {
|
|
36
|
+
description: 'Distilled own-ads performance (totals, per-platform, top campaigns, WoW).',
|
|
37
|
+
usage: 'ads performance [--from 2026-06-01] [--to 2026-06-30]',
|
|
38
|
+
flags: [
|
|
39
|
+
{ name: 'from', description: 'Window start (YYYY-MM-DD); defaults to 30 days ago' },
|
|
40
|
+
{ name: 'to', description: 'Window end (YYYY-MM-DD); defaults to today' },
|
|
41
|
+
],
|
|
42
|
+
async handler(ctx) {
|
|
43
|
+
const res = await apiRequest(ctx.profileName, 'GET', '/studio/ads-performance', {
|
|
44
|
+
query: { fromDate: ctx.flag('from'), toDate: ctx.flag('to') },
|
|
45
|
+
});
|
|
46
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
47
|
+
if (!res?.hasAds) { info('(no connected ad accounts, or ad data is unavailable right now)'); return; }
|
|
48
|
+
const t = res.totals || {};
|
|
49
|
+
kv({
|
|
50
|
+
window: res.window ? `${res.window.fromDate} → ${res.window.toDate}` : '—',
|
|
51
|
+
spend: t.spend,
|
|
52
|
+
impressions: t.impressions,
|
|
53
|
+
clicks: t.clicks,
|
|
54
|
+
ctr: t.ctr !== undefined ? `${t.ctr}%` : '—',
|
|
55
|
+
cpc: t.cpc,
|
|
56
|
+
conversions: t.conversions,
|
|
57
|
+
roas: t.roas,
|
|
58
|
+
campaigns: `${res.activeCampaignCount ?? '—'} active / ${res.campaignCount ?? '—'} total`,
|
|
59
|
+
}, ctx);
|
|
60
|
+
const byPlatform = res.byPlatform || {};
|
|
61
|
+
const pRows = Object.entries(byPlatform).map(([platform, m]) => ({
|
|
62
|
+
platform, spend: m.spend, conversions: m.conversions, roas: m.roas,
|
|
63
|
+
}));
|
|
64
|
+
if (pRows.length) {
|
|
65
|
+
info('\nBy platform:');
|
|
66
|
+
table(pRows, [
|
|
67
|
+
{ key: 'platform', label: 'PLATFORM' },
|
|
68
|
+
{ key: 'spend', label: 'SPEND' },
|
|
69
|
+
{ key: 'conversions', label: 'CONV' },
|
|
70
|
+
{ key: 'roas', label: 'ROAS' },
|
|
71
|
+
], ctx);
|
|
72
|
+
}
|
|
73
|
+
const top = res.activeCampaigns || [];
|
|
74
|
+
if (top.length) {
|
|
75
|
+
info('\nTop active campaigns:');
|
|
76
|
+
table(top.map((c) => ({
|
|
77
|
+
name: c.name, platform: c.platform, spend: c.spend, roas: c.roas, conversions: c.conversions,
|
|
78
|
+
})), [
|
|
79
|
+
{ key: 'name', label: 'CAMPAIGN', max: 34 },
|
|
80
|
+
{ key: 'platform', label: 'PLATFORM' },
|
|
81
|
+
{ key: 'spend', label: 'SPEND' },
|
|
82
|
+
{ key: 'roas', label: 'ROAS' },
|
|
83
|
+
{ key: 'conversions', label: 'CONV' },
|
|
84
|
+
], ctx);
|
|
85
|
+
}
|
|
86
|
+
info('\n(use --json for the full aggregate incl. WoW deltas)');
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
campaigns: {
|
|
91
|
+
description: 'List your ad campaigns with per-campaign metrics.',
|
|
92
|
+
usage: 'ads campaigns [--status active] [--from ...] [--to ...] [--limit 25]',
|
|
93
|
+
flags: [
|
|
94
|
+
{ name: 'status', description: 'Status filter, e.g. active / paused' },
|
|
95
|
+
{ name: 'from', description: 'Window start (YYYY-MM-DD)' },
|
|
96
|
+
{ name: 'to', description: 'Window end (YYYY-MM-DD)' },
|
|
97
|
+
{ name: 'limit', description: 'Max campaigns (default 25)' },
|
|
98
|
+
],
|
|
99
|
+
async handler(ctx) {
|
|
100
|
+
const res = await apiRequest(ctx.profileName, 'GET', '/studio/ads-channels/campaigns', {
|
|
101
|
+
query: {
|
|
102
|
+
status: ctx.flag('status'),
|
|
103
|
+
fromDate: ctx.flag('from'),
|
|
104
|
+
toDate: ctx.flag('to'),
|
|
105
|
+
limit: ctx.flag('limit'),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const campaigns = res?.campaigns || res?.data || [];
|
|
109
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
110
|
+
if (!Array.isArray(campaigns) || campaigns.length === 0) { info('(no campaigns)'); return; }
|
|
111
|
+
const rows = campaigns.map((c) => ({
|
|
112
|
+
name: c.name || '(unnamed)',
|
|
113
|
+
platform: c.platform || '—',
|
|
114
|
+
status: c.status || '—',
|
|
115
|
+
spend: c.metrics?.spend ?? c.spend ?? '—',
|
|
116
|
+
roas: c.metrics?.roas ?? c.roas ?? '—',
|
|
117
|
+
conversions: c.metrics?.conversions ?? c.conversions ?? '—',
|
|
118
|
+
}));
|
|
119
|
+
table(rows, [
|
|
120
|
+
{ key: 'name', label: 'CAMPAIGN', max: 34 },
|
|
121
|
+
{ key: 'platform', label: 'PLATFORM' },
|
|
122
|
+
{ key: 'status', label: 'STATUS' },
|
|
123
|
+
{ key: 'spend', label: 'SPEND' },
|
|
124
|
+
{ key: 'roas', label: 'ROAS' },
|
|
125
|
+
{ key: 'conversions', label: 'CONV' },
|
|
126
|
+
], ctx);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
package/src/commands/discover.js
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
// discover: find social profiles for a company (async job) + manage the
|
|
2
2
|
// weekly competitor suggestions.
|
|
3
3
|
import { apiRequest } from '../http.js';
|
|
4
|
+
import { getCredentials } from '../config.js';
|
|
5
|
+
import { withDb, findUserOrThrow, backendImport } from '../local.js';
|
|
4
6
|
import { out, table, info } from '../output.js';
|
|
5
7
|
|
|
6
8
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
9
|
|
|
10
|
+
function opsEmail(ctx) {
|
|
11
|
+
const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
|
|
12
|
+
if (!email) throw new Error('Provide --email (or log in) to select the target user.');
|
|
13
|
+
return email;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Render a pending-suggestions list as a table (shared by `suggestions` and
|
|
17
|
+
// the post-generation output).
|
|
18
|
+
function renderSuggestions(list, ctx) {
|
|
19
|
+
const rows = (Array.isArray(list) ? list : []).map((s) => ({
|
|
20
|
+
id: String(s._id || s.id || '—'),
|
|
21
|
+
name: s.candidateName || s.name || s.companyName || '—',
|
|
22
|
+
reason: s.reason || s.rationale || '—',
|
|
23
|
+
}));
|
|
24
|
+
table(rows, [{ key: 'id', label: 'ID' }, { key: 'name', label: 'NAME' }, { key: 'reason', label: 'REASON', max: 60 }], ctx);
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
export default {
|
|
9
28
|
description: 'Discovery — find profiles for a company and manage suggestions.',
|
|
10
29
|
commands: {
|
|
@@ -58,17 +77,40 @@ export default {
|
|
|
58
77
|
},
|
|
59
78
|
|
|
60
79
|
suggestions: {
|
|
61
|
-
description: 'List competitor suggestions.',
|
|
80
|
+
description: 'List competitor suggestions (add --generate to force a fresh run first).',
|
|
81
|
+
usage: 'discover suggestions [--generate] [--local] [--email you@co.com]',
|
|
82
|
+
flags: [
|
|
83
|
+
{ name: 'generate', boolean: true, description: 'Force a fresh suggestion run before listing (LLM + web search; rate-limited)' },
|
|
84
|
+
{ name: 'local', boolean: true, description: 'Run the generator engine directly (ops; needs repo + .env)' },
|
|
85
|
+
{ name: 'email', description: 'Target user in --local mode (defaults to logged-in profile)' },
|
|
86
|
+
],
|
|
62
87
|
async handler(ctx) {
|
|
88
|
+
if (ctx.bool('generate')) {
|
|
89
|
+
if (ctx.useLocal) {
|
|
90
|
+
const email = opsEmail(ctx);
|
|
91
|
+
const result = await withDb(async () => {
|
|
92
|
+
const user = await findUserOrThrow(email);
|
|
93
|
+
const { generateSuggestionsForUser } = await backendImport('utils/competitorSuggestionGenerator.js');
|
|
94
|
+
return generateSuggestionsForUser(user, { debounceMinutes: 0 });
|
|
95
|
+
});
|
|
96
|
+
if (ctx.json) { out({ success: true, email, result }, ctx); return; }
|
|
97
|
+
info(`✓ Suggestion generation run for ${email} (local). ${result?.skipped ? `Skipped: ${result.skipped}.` : `Created: ${result?.created ?? 0}.`}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
info('Generating suggestions (LLM + web search — this can take up to a minute)…');
|
|
101
|
+
const gen = await apiRequest(ctx.profileName, 'POST', '/competitor-suggestions/generate', { body: {} });
|
|
102
|
+
const list = gen?.data || [];
|
|
103
|
+
if (ctx.json) { out(gen, ctx); return; }
|
|
104
|
+
if (gen?.result?.skipped) info(`(generation skipped: ${gen.result.skipped})`);
|
|
105
|
+
else info(`✓ Generated. ${gen?.result?.created ?? 0} new suggestion(s).`);
|
|
106
|
+
renderSuggestions(list, ctx);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
63
110
|
const res = await apiRequest(ctx.profileName, 'GET', '/competitor-suggestions');
|
|
64
111
|
const list = res?.data || res?.suggestions || res || [];
|
|
65
112
|
if (ctx.json) { out(list, ctx); return; }
|
|
66
|
-
|
|
67
|
-
id: String(s._id || s.id || '—'),
|
|
68
|
-
name: s.name || s.companyName || '—',
|
|
69
|
-
reason: s.reason || s.rationale || '—',
|
|
70
|
-
}));
|
|
71
|
-
table(rows, [{ key: 'id', label: 'ID' }, { key: 'name', label: 'NAME' }, { key: 'reason', label: 'REASON', max: 60 }], ctx);
|
|
113
|
+
renderSuggestions(list, ctx);
|
|
72
114
|
},
|
|
73
115
|
},
|
|
74
116
|
|
package/src/index.js
CHANGED
|
@@ -13,11 +13,11 @@ import authGroup from './commands/auth.js';
|
|
|
13
13
|
import configGroup from './commands/config.js';
|
|
14
14
|
import competitorsGroup from './commands/competitors.js';
|
|
15
15
|
import discoverGroup from './commands/discover.js';
|
|
16
|
-
import scrapeGroup from './commands/scrape.js';
|
|
17
16
|
import insightsGroup from './commands/insights.js';
|
|
18
17
|
import alertsGroup from './commands/alerts.js';
|
|
19
18
|
import battlecardGroup from './commands/battlecard.js';
|
|
20
19
|
import playbookGroup from './commands/playbook.js';
|
|
20
|
+
import adsGroup from './commands/ads.js';
|
|
21
21
|
import studioGroup from './commands/studio.js';
|
|
22
22
|
import artifactGroup from './commands/artifact.js';
|
|
23
23
|
import adminGroup from './commands/admin.js';
|
|
@@ -26,7 +26,7 @@ import { askCommand } from './commands/ask.js';
|
|
|
26
26
|
export function buildProgram() {
|
|
27
27
|
return {
|
|
28
28
|
name: 'oppira',
|
|
29
|
-
version: '0.1.
|
|
29
|
+
version: '0.1.2',
|
|
30
30
|
description: 'Terminal control for Oppira — competitor intelligence, playbook, studio & ops.',
|
|
31
31
|
groups: {
|
|
32
32
|
auth: authGroup,
|
|
@@ -34,11 +34,11 @@ export function buildProgram() {
|
|
|
34
34
|
competitors: competitorsGroup,
|
|
35
35
|
comp: competitorsGroup, // alias
|
|
36
36
|
discover: discoverGroup,
|
|
37
|
-
scrape: scrapeGroup,
|
|
38
37
|
insights: insightsGroup,
|
|
39
38
|
alerts: alertsGroup,
|
|
40
39
|
battlecard: battlecardGroup,
|
|
41
40
|
playbook: playbookGroup,
|
|
41
|
+
ads: adsGroup,
|
|
42
42
|
studio: studioGroup,
|
|
43
43
|
artifact: artifactGroup,
|
|
44
44
|
admin: adminGroup,
|
package/src/local.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Ops / "local mode". Runs commands against MongoDB + the backend's engine
|
|
2
2
|
// functions directly, instead of over HTTP. This is how the CLI reaches the
|
|
3
|
-
// cron-only generators (battlecard / insights / alerts / summary)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// cron-only generators (battlecard / insights / alerts / summary) without
|
|
4
|
+
// needing new HTTP endpoints — mirroring the existing one-off scripts in
|
|
5
|
+
// ../scripts (dotenv + mongoose.connect + engine).
|
|
6
6
|
//
|
|
7
7
|
// Only usable from a repo checkout with MONGO_URI available; the published
|
|
8
8
|
// customer build never exercises this path.
|
package/src/commands/scrape.js
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// scrape: on-demand intelligent scrape (local engine) + raw per-platform
|
|
2
|
-
// endpoints (remote). The intelligent path is the one that mirrors S3, recomputes
|
|
3
|
-
// summaries, and fires battlecard/profile triggers — the raw path hits a single
|
|
4
|
-
// Apify actor and bypasses that pipeline (and is currently unauthenticated).
|
|
5
|
-
import { apiRequest } from '../http.js';
|
|
6
|
-
import { getCredentials } from '../config.js';
|
|
7
|
-
import { withDb, findUserOrThrow, findCompetitorsForUser, backendImport } from '../local.js';
|
|
8
|
-
import { out, info } from '../output.js';
|
|
9
|
-
|
|
10
|
-
// CLI short form → canonical platform key used by scrapeCompetitorIntelligent.
|
|
11
|
-
const PLATFORM_ALIASES = {
|
|
12
|
-
fb: 'facebook', facebook: 'facebook',
|
|
13
|
-
ig: 'instagram', instagram: 'instagram',
|
|
14
|
-
x: 'x', twitter: 'x',
|
|
15
|
-
li: 'linkedin', linkedin: 'linkedin',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const RAW_ENDPOINTS = {
|
|
19
|
-
facebook: '/scrape-facebook',
|
|
20
|
-
'facebook-posts': '/scrape-facebook-posts',
|
|
21
|
-
'facebook-ads': '/scrape-facebook-ads',
|
|
22
|
-
'google-ads': '/scrape-google-ads',
|
|
23
|
-
instagram: '/scrape-instagram',
|
|
24
|
-
twitter: '/scrape-twitter',
|
|
25
|
-
linkedin: '/scrape-linkedin',
|
|
26
|
-
trustpilot: '/scrape-trustpilot',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function opsEmail(ctx) {
|
|
30
|
-
const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
|
|
31
|
-
if (!email) throw new Error('Provide --email (or log in) to select the target user.');
|
|
32
|
-
return email;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export default {
|
|
36
|
-
description: 'Scraping — on-demand intelligent scrape (ops) + raw per-platform.',
|
|
37
|
-
commands: {
|
|
38
|
-
run: {
|
|
39
|
-
description: 'Intelligent on-demand scrape of a competitor now (ops / local mode).',
|
|
40
|
-
usage: 'scrape run <competitor> [--platforms fb,ig,x,li] [--email you@co.com]',
|
|
41
|
-
flags: [
|
|
42
|
-
{ name: 'platforms', description: 'Comma list to limit (fb,ig,x,li); default all social' },
|
|
43
|
-
{ name: 'email', description: 'Target user (defaults to logged-in profile)' },
|
|
44
|
-
],
|
|
45
|
-
async handler(ctx) {
|
|
46
|
-
const email = opsEmail(ctx);
|
|
47
|
-
const nameQuery = ctx.arg(0);
|
|
48
|
-
if (!nameQuery) throw new Error('Usage: oppira scrape run <competitor> [--platforms fb,ig]');
|
|
49
|
-
|
|
50
|
-
let allowedPlatforms = null;
|
|
51
|
-
if (ctx.flag('platforms')) {
|
|
52
|
-
allowedPlatforms = String(ctx.flag('platforms')).split(',')
|
|
53
|
-
.map((p) => PLATFORM_ALIASES[p.trim().toLowerCase()])
|
|
54
|
-
.filter(Boolean);
|
|
55
|
-
if (allowedPlatforms.length === 0) throw new Error('No valid platforms in --platforms (use fb,ig,x,li).');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const result = await withDb(async () => {
|
|
59
|
-
const user = await findUserOrThrow(email);
|
|
60
|
-
const competitors = await findCompetitorsForUser(user, nameQuery);
|
|
61
|
-
if (competitors.length === 0) throw new Error(`No competitor matches "${nameQuery}" for ${email}.`);
|
|
62
|
-
if (competitors.length > 1) throw new Error(`Ambiguous: ${competitors.map((c) => c.name).join(', ')}.`);
|
|
63
|
-
const { scrapeCompetitorIntelligent } = await backendImport('utils/scraperUtils.js');
|
|
64
|
-
info(`Scraping "${competitors[0].name}"${allowedPlatforms ? ` (${allowedPlatforms.join(', ')})` : ''}…`);
|
|
65
|
-
// requireScrapingFlag:false → on-demand semantics (scrape platforms not
|
|
66
|
-
// currently mid-scrape), rather than the cron's flag-driven path.
|
|
67
|
-
return scrapeCompetitorIntelligent(competitors[0], user, allowedPlatforms, { requireScrapingFlag: false });
|
|
68
|
-
});
|
|
69
|
-
if (ctx.json) { out({ success: true, email, competitor: nameQuery, result }, ctx); return; }
|
|
70
|
-
info(`✓ Scrape complete for "${nameQuery}".`);
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
raw: {
|
|
75
|
-
description: 'Call a single raw platform scraper (remote). Bypasses the intelligent pipeline.',
|
|
76
|
-
usage: 'scrape raw <platform> --url <url> (platforms: ' + Object.keys(RAW_ENDPOINTS).join(', ') + ')',
|
|
77
|
-
flags: [{ name: 'url', description: 'Profile / page URL to scrape' }],
|
|
78
|
-
async handler(ctx) {
|
|
79
|
-
const platform = ctx.arg(0);
|
|
80
|
-
const endpoint = RAW_ENDPOINTS[platform];
|
|
81
|
-
if (!endpoint) throw new Error(`Unknown platform "${platform}". One of: ${Object.keys(RAW_ENDPOINTS).join(', ')}`);
|
|
82
|
-
const url = ctx.flag('url');
|
|
83
|
-
if (!url) throw new Error('Usage: oppira scrape raw <platform> --url <url>');
|
|
84
|
-
const email = getCredentials(ctx.profileName).email;
|
|
85
|
-
const res = await apiRequest(ctx.profileName, 'POST', endpoint, {
|
|
86
|
-
body: { url, userEmails: email || '' },
|
|
87
|
-
});
|
|
88
|
-
if (ctx.json) { out(res, ctx); return; }
|
|
89
|
-
info(`✓ Raw ${platform} scrape done.`);
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
};
|