@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 CHANGED
@@ -1,147 +1,98 @@
1
- # Oppira CLI (`oppira`)
1
+ # Oppira CLI
2
2
 
3
- Terminal control for Oppira — competitor intelligence, playbook, studio, and ops —
4
- in one command. Dependency-free Node ESM (Node 20): runs with just `node`.
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
- ## Two execution modes
7
+ ## Requirements
7
8
 
8
- | Mode | How | Serves | Auth |
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
- **From the repo (team / ops mode):**
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 # or leave default http://localhost:3008
42
- oppira auth login --email you@co.com # stores a JWT session (~/.oppira)
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
- ## Authentication: session vs. API key
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
- Two ways to authenticate — pick one per profile:
31
+ ## Authentication
49
32
 
50
- 1. **Login session (works everywhere, default):**
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
- 2. **API key (durable — best for CI / agents / automation):**
57
- Generate the key in the **Oppira web app**, then paste it into the CLI:
58
- ```bash
59
- oppira auth key set ca_xxxxxxxx # or: oppira auth key generate
60
- ```
61
- > A `ca_` key unlocks the read-only `/mcp` analytics out of the box. To use it
62
- > for the **full REST command set**, the server must have
63
- > `OPPIRA_CLI_APIKEY_REST=true`. Until that's enabled, use `oppira auth login`
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
- Config lives in `~/.oppira/config.json`; credentials (tokens, key) in
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
- auth login | logout | whoami | key generate|revoke
74
- config show | list | set-url | set-output | use
75
- comp list | show | add | track | toggle-tracking | insights | alerts | rm
76
- discover profiles | job | suggestions | dismiss
77
- scrape run (intelligent, local) | raw <platform> --url
78
- insights list | summary | generate* (*local/ops force-run)
79
- alerts list | run*
80
- battlecard show | history | regen*
81
- playbook show | bootstrap | refresh-section | refresh-cluster
82
- studio posts | publish | calendars | calendar-generate | comments | draft-reply
83
- artifact get | export
84
- ask "<question>" (streams the unified agent reply)
85
- admin stats | ai-usage | errors | users* | set-plan*
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
- Global flags: `--json` (machine output for scripts/agents), `--local`,
89
- `--profile <name>`, `--help`, `--version`. Every command exits non-zero on failure.
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
- ### Examples
68
+ ## Examples
92
69
 
93
70
  ```bash
94
- oppira ask "compare Acme and Globex activity this month"
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
- ## Server-side: enable API-key REST access (opt-in)
74
+ # Competitors
75
+ oppira comp show Acme
76
+ oppira comp add "Globex" --website globex.com --instagram https://instagram.com/globex
104
77
 
105
- Granting a `ca_` key full REST authority (so users/CI can drive the CLI with just
106
- an API key instead of a 15-min JWT) is a deliberate security decision and is **off
107
- by default**. To turn on the "generate a key in the web app → use the CLI" flow,
108
- set on the server:
78
+ # Refresh strategic docs on demand
79
+ oppira insights generate
80
+ oppira battlecard regen Acme
109
81
 
110
- ```
111
- OPPIRA_CLI_APIKEY_REST=true
82
+ # Export a generated report
83
+ oppira artifact export <id> --format pdf
112
84
  ```
113
85
 
114
- Until then, REST commands authenticate with the JWT session from `oppira auth login`.
86
+ ## Scripting & automation
115
87
 
116
- ## Publishing (maintainers)
117
-
118
- The package is publish-ready (`publishConfig.access = public`, `files` whitelist).
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
- cd cli
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
- Prerequisites & notes:
127
- - You must own/belong to the **`@oppira`** npm org for the scoped name. If you don't,
128
- rename `name` in package.json to an unscoped `oppira-cli` (the `oppira` command name
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oppira/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Oppira CLI — terminal control for the adaptive marketing system (competitor intelligence, playbook, studio, ops).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ };
@@ -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
- const rows = (Array.isArray(list) ? list : []).map((s) => ({
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.0',
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) and the
4
- // intelligent scraper without needing new HTTP endpoints — mirroring the
5
- // existing one-off scripts in ../scripts (dotenv + mongoose.connect + engine).
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.
@@ -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
- };