@krunal202/apple-search-ads-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LLM_GUIDE.md +251 -0
- package/README.md +84 -0
- package/bin/asa.js +2 -0
- package/package.json +39 -0
- package/src/api/auth.js +60 -0
- package/src/api/client.js +79 -0
- package/src/cli.js +70 -0
- package/src/commands/adgroups.js +92 -0
- package/src/commands/apps.js +16 -0
- package/src/commands/auth.js +108 -0
- package/src/commands/campaigns.js +90 -0
- package/src/commands/clipboard.js +100 -0
- package/src/commands/guide.js +9 -0
- package/src/commands/helpers.js +27 -0
- package/src/commands/keywords.js +102 -0
- package/src/commands/memory.js +69 -0
- package/src/commands/negatives.js +77 -0
- package/src/commands/stats.js +81 -0
- package/src/config.js +35 -0
- package/src/errors.js +9 -0
- package/src/memory.js +54 -0
- package/src/output.js +17 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { api, apiAll } from '../api/client.js';
|
|
3
|
+
import { AsaError } from '../errors.js';
|
|
4
|
+
import { print } from '../output.js';
|
|
5
|
+
import { parseJsonOption } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
const MATCH_TYPES = ['BROAD', 'EXACT'];
|
|
8
|
+
|
|
9
|
+
const base = (campaignId, adgroupId) =>
|
|
10
|
+
adgroupId
|
|
11
|
+
? `/campaigns/${campaignId}/adgroups/${adgroupId}/negativekeywords`
|
|
12
|
+
: `/campaigns/${campaignId}/negativekeywords`;
|
|
13
|
+
|
|
14
|
+
const scopeDesc = (adgroupId) => (adgroupId ? 'ad-group-level' : 'campaign-level');
|
|
15
|
+
|
|
16
|
+
export function registerNegatives(program) {
|
|
17
|
+
const cmd = new Command('negatives').description('Manage negative keywords (campaign-level, or ad-group-level with --adgroup)');
|
|
18
|
+
|
|
19
|
+
cmd.command('list').description('List negative keywords')
|
|
20
|
+
.requiredOption('--campaign <id>', 'campaign ID')
|
|
21
|
+
.option('--adgroup <id>', 'ad group ID (omit for campaign-level negatives)')
|
|
22
|
+
.option('--limit <n>', 'max results (1-1000)', '20')
|
|
23
|
+
.option('--offset <n>', 'result offset', '0')
|
|
24
|
+
.option('--all', 'fetch every page')
|
|
25
|
+
.action(async (opts) => {
|
|
26
|
+
if (opts.all) return print({ data: await apiAll(base(opts.campaign, opts.adgroup)), error: null });
|
|
27
|
+
print(await api('GET', base(opts.campaign, opts.adgroup), { query: { limit: opts.limit, offset: opts.offset } }));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
cmd.command('create').description('Create negative keywords (bulk via --data)')
|
|
31
|
+
.requiredOption('--campaign <id>', 'campaign ID')
|
|
32
|
+
.option('--adgroup <id>', 'ad group ID (omit for campaign-level negatives)')
|
|
33
|
+
.option('--text <text>', 'keyword text')
|
|
34
|
+
.option('--match-type <type>', 'BROAD or EXACT', 'BROAD')
|
|
35
|
+
.option('--data <json>', 'raw JSON array for bulk create, e.g. \'[{"text":"free","matchType":"BROAD"}]\'')
|
|
36
|
+
.action(async (opts) => {
|
|
37
|
+
let body;
|
|
38
|
+
if (opts.data) {
|
|
39
|
+
body = parseJsonOption(opts.data, '--data');
|
|
40
|
+
if (!Array.isArray(body)) body = [body];
|
|
41
|
+
} else {
|
|
42
|
+
if (!opts.text) {
|
|
43
|
+
throw new AsaError('--text is required (or pass a bulk array with --data)', {
|
|
44
|
+
hint: `Example: asa negatives create --campaign 123 --text free --match-type BROAD (${scopeDesc(opts.adgroup)})`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const matchType = opts.matchType.toUpperCase();
|
|
48
|
+
if (!MATCH_TYPES.includes(matchType)) {
|
|
49
|
+
throw new AsaError(`Invalid match type "${opts.matchType}"`, {
|
|
50
|
+
hint: `Valid match types: ${MATCH_TYPES.join(', ')}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
body = [{ text: opts.text, matchType, status: 'ACTIVE' }];
|
|
54
|
+
}
|
|
55
|
+
print(await api('POST', `${base(opts.campaign, opts.adgroup)}/bulk`, { body }));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
cmd.command('delete').description('Delete negative keywords (bulk via --data)')
|
|
59
|
+
.requiredOption('--campaign <id>', 'campaign ID')
|
|
60
|
+
.option('--adgroup <id>', 'ad group ID (omit for campaign-level negatives)')
|
|
61
|
+
.option('--id <id>', 'negative keyword ID')
|
|
62
|
+
.option('--data <json>', 'raw JSON array of IDs for bulk delete, e.g. \'[123,456]\'')
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
let ids;
|
|
65
|
+
if (opts.data) {
|
|
66
|
+
ids = parseJsonOption(opts.data, '--data');
|
|
67
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
68
|
+
} else if (opts.id) {
|
|
69
|
+
ids = [Number(opts.id)];
|
|
70
|
+
} else {
|
|
71
|
+
throw new AsaError('--id is required (or pass an ID array with --data)');
|
|
72
|
+
}
|
|
73
|
+
print(await api('POST', `${base(opts.campaign, opts.adgroup)}/delete/bulk`, { body: ids }));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
program.addCommand(cmd);
|
|
77
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { api } from '../api/client.js';
|
|
3
|
+
import { AsaError } from '../errors.js';
|
|
4
|
+
import { print } from '../output.js';
|
|
5
|
+
import { collect, csv, parseJsonOption } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
const LEVELS = ['campaigns', 'adgroups', 'keywords', 'searchterms', 'ads'];
|
|
8
|
+
const GRANULARITIES = ['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'];
|
|
9
|
+
const OPERATORS = ['EQUALS', 'IN', 'NOT_IN', 'GREATER_THAN', 'LESS_THAN', 'STARTSWITH', 'CONTAINS'];
|
|
10
|
+
|
|
11
|
+
export function parseCondition(expr) {
|
|
12
|
+
const match = expr.match(/^([A-Za-z]+)=([A-Z_]+)=(.+)$/);
|
|
13
|
+
if (!match || !OPERATORS.includes(match[2])) {
|
|
14
|
+
throw new AsaError(`Invalid condition "${expr}"`, {
|
|
15
|
+
hint: `Format: field=OPERATOR=value. Operators: ${OPERATORS.join(', ')}. Example: --condition campaignId=EQUALS=123456`,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const [, field, operator, rawValue] = match;
|
|
19
|
+
const values = rawValue.includes(',') ? csv(rawValue) : rawValue;
|
|
20
|
+
return { field, operator, values: Array.isArray(values) ? values : [values] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerStats(program) {
|
|
24
|
+
program.command('stats')
|
|
25
|
+
.description('Fetch performance reports (impressions, taps, spend, installs...)')
|
|
26
|
+
.argument('<level>', `report level: ${LEVELS.join(' | ')}`)
|
|
27
|
+
.requiredOption('--start <date>', 'start date, YYYY-MM-DD')
|
|
28
|
+
.requiredOption('--end <date>', 'end date, YYYY-MM-DD')
|
|
29
|
+
.option('--campaign <id>', 'campaign ID (required for adgroups, keywords, searchterms and ads levels)')
|
|
30
|
+
.option('--granularity <g>', `one of: ${GRANULARITIES.join(', ')}`)
|
|
31
|
+
.option('--condition <expr>', 'filter as field=OPERATOR=value (repeatable)', collect, [])
|
|
32
|
+
.option('--order-by <expr>', 'sort as field=ASCENDING|DESCENDING', 'localSpend=DESCENDING')
|
|
33
|
+
.option('--group-by <fields>', 'comma-separated fields to group by, e.g. countryOrRegion')
|
|
34
|
+
.option('--include-no-metrics', 'include rows without metrics', false)
|
|
35
|
+
.option('--data <json>', 'raw JSON selector/body, merged over the flag-built body')
|
|
36
|
+
.action(async (level, opts) => {
|
|
37
|
+
if (!LEVELS.includes(level)) {
|
|
38
|
+
throw new AsaError(`Unknown report level "${level}"`, {
|
|
39
|
+
hint: `Valid levels: ${LEVELS.join(', ')}. Example: asa stats campaigns --start 2026-09-01 --end 2026-09-15`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (opts.granularity && !GRANULARITIES.includes(opts.granularity.toUpperCase())) {
|
|
43
|
+
throw new AsaError(`Invalid granularity "${opts.granularity}"`, { hint: `Valid: ${GRANULARITIES.join(', ')}` });
|
|
44
|
+
}
|
|
45
|
+
const conditions = opts.condition.map(parseCondition);
|
|
46
|
+
const orderMatch = opts.orderBy.match(/^([A-Za-z]+)=(ASCENDING|DESCENDING)$/i);
|
|
47
|
+
if (!orderMatch) {
|
|
48
|
+
throw new AsaError(`Invalid --order-by "${opts.orderBy}"`, {
|
|
49
|
+
hint: 'Format: field=ASCENDING or field=DESCENDING, e.g. --order-by localSpend=DESCENDING',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const base = {
|
|
53
|
+
startTime: opts.start,
|
|
54
|
+
endTime: opts.end,
|
|
55
|
+
timeZone: 'ORTZ',
|
|
56
|
+
granularity: opts.granularity?.toUpperCase(),
|
|
57
|
+
groupBy: opts.groupBy ? csv(opts.groupBy) : undefined,
|
|
58
|
+
returnRowTotals: true,
|
|
59
|
+
returnGrandTotals: true,
|
|
60
|
+
returnRecordsWithNoMetrics: opts.includeNoMetrics,
|
|
61
|
+
selector: {
|
|
62
|
+
conditions,
|
|
63
|
+
orderBy: [{ field: orderMatch[1], sortOrder: orderMatch[2].toUpperCase() }],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
for (const key of Object.keys(base)) if (base[key] === undefined) delete base[key];
|
|
67
|
+
const body = opts.data ? { ...base, ...parseJsonOption(opts.data, '--data') } : base;
|
|
68
|
+
let resource;
|
|
69
|
+
if (level === 'campaigns') {
|
|
70
|
+
resource = '/reports/campaigns';
|
|
71
|
+
} else {
|
|
72
|
+
if (!opts.campaign) {
|
|
73
|
+
throw new AsaError(`--campaign is required for the "${level}" report level (API v5 scopes reports under campaigns)`, {
|
|
74
|
+
hint: `Example: asa stats ${level} --campaign 123 --start ${opts.start} --end ${opts.end}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
resource = `/reports/campaigns/${opts.campaign}/${level}`;
|
|
78
|
+
}
|
|
79
|
+
print(await api('POST', resource, { body }));
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const configDir = process.env.ASA_CONFIG_DIR || path.join(os.homedir(), '.asa');
|
|
6
|
+
|
|
7
|
+
const file = (name) => path.join(configDir, name);
|
|
8
|
+
|
|
9
|
+
export function readJson(name) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(file(name), 'utf8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeJson(name, data) {
|
|
18
|
+
const target = file(name);
|
|
19
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
20
|
+
fs.writeFileSync(target, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function remove(...names) {
|
|
24
|
+
for (const name of names) fs.rmSync(file(name), { force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getCredentials() {
|
|
28
|
+
return readJson('config.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getOrgId(override) {
|
|
32
|
+
if (override) return Number(override);
|
|
33
|
+
const config = getCredentials();
|
|
34
|
+
return config?.orgId;
|
|
35
|
+
}
|
package/src/errors.js
ADDED
package/src/memory.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { configDir, getOrgId, readJson, writeJson } from './config.js';
|
|
4
|
+
|
|
5
|
+
const ACTIVITY_KEEP = 500;
|
|
6
|
+
const ACTIVITY_TRIM_TO = 250;
|
|
7
|
+
|
|
8
|
+
const dir = (orgId) => path.join(configDir, 'memory', String(orgId ?? 'unknown'));
|
|
9
|
+
|
|
10
|
+
function appendJsonl(file, entry) {
|
|
11
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
12
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function trimJsonl(file) {
|
|
16
|
+
try {
|
|
17
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
18
|
+
if (lines.length > ACTIVITY_KEEP) {
|
|
19
|
+
fs.writeFileSync(file, lines.slice(-ACTIVITY_TRIM_TO).join('\n') + '\n', { mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
} catch { /* no file yet */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function logActivity(entry, orgId = getOrgId()) {
|
|
25
|
+
if (!orgId) return;
|
|
26
|
+
const file = path.join(dir(orgId), 'activity.jsonl');
|
|
27
|
+
appendJsonl(file, { ts: new Date().toISOString(), ...entry });
|
|
28
|
+
trimJsonl(file);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readActivity(orgId = getOrgId(), limit = 20) {
|
|
32
|
+
try {
|
|
33
|
+
const lines = fs.readFileSync(path.join(dir(orgId), 'activity.jsonl'), 'utf8').split('\n').filter(Boolean);
|
|
34
|
+
return lines.slice(-limit).map((line) => JSON.parse(line));
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function addNote(note, refs = {}, orgId = getOrgId()) {
|
|
41
|
+
const name = path.join('memory', String(orgId), 'notes.json');
|
|
42
|
+
const notes = readJson(name) ?? [];
|
|
43
|
+
notes.push({ ts: new Date().toISOString(), note, ...refs });
|
|
44
|
+
writeJson(name, notes);
|
|
45
|
+
return notes.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getNotes(orgId = getOrgId()) {
|
|
49
|
+
return readJson(path.join('memory', String(orgId), 'notes.json')) ?? [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearMemory(orgId = getOrgId()) {
|
|
53
|
+
fs.rmSync(dir(orgId), { recursive: true, force: true });
|
|
54
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
let pretty = false;
|
|
2
|
+
|
|
3
|
+
export function setPretty(value) {
|
|
4
|
+
pretty = value;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function print(data) {
|
|
8
|
+
console.log(pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function printError(err) {
|
|
12
|
+
const body = { error: err.message };
|
|
13
|
+
if (err.status) body.status = err.status;
|
|
14
|
+
if (err.hint) body.hint = err.hint;
|
|
15
|
+
if (err.details !== undefined) body.details = err.details;
|
|
16
|
+
console.error(pretty ? JSON.stringify(body, null, 2) : JSON.stringify(body));
|
|
17
|
+
}
|