Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
781 changes: 781 additions & 0 deletions cli-manifest.json

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions clis/pinterest/board-create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Pinterest board-create β€” create a new board (BoardResource/create).
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { PINTEREST_BASE, pinterestResourceCreate } from './utils.js';

const PRIVACY = ['public', 'secret'];

cli({
site: 'pinterest',
name: 'board-create',
access: 'write',
description: 'Create a new board on your account',
domain: 'www.pinterest.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'name', type: 'string', positional: true, required: true, help: 'Board name' },
{ name: 'description', type: 'string', default: '', help: 'Board description' },
{ name: 'privacy', type: 'string', default: 'public', choices: PRIVACY, help: 'public or secret' },
],
columns: ['boardId', 'name', 'privacy', 'url'],
func: async (page, kwargs) => {
const name = String(kwargs.name ?? '').trim();
if (!name) throw new ArgumentError('board name is required');
const description = String(kwargs.description ?? '').trim();
const privacy = String(kwargs.privacy ?? 'public');
if (!PRIVACY.includes(privacy)) {
throw new ArgumentError(`Unknown privacy "${privacy}". Valid: ${PRIVACY.join(', ')}`);
}

await page.goto(`${PINTEREST_BASE}/`);

const options = { name, privacy };
if (description) options.description = description;

const created = await pinterestResourceCreate(page, 'BoardResource', options, '/');
const boardId = created && created.id;
if (!boardId) {
throw new CommandExecutionError('Board creation did not return a board id');
}

return [{
boardId: String(boardId),
name: created.name || name,
privacy: created.privacy || privacy,
url: created.url ? `${PINTEREST_BASE}${created.url}` : '',
}];
},
});
49 changes: 49 additions & 0 deletions clis/pinterest/board-create.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { createPageMock } from '../test-utils.js';
import './board-create.js';

let cmd;

function createdBoard(id) {
return { resource_response: { data: id ? { id, name: 'My Board', privacy: 'public', url: '/me/my-board/' } : {} } };
}

beforeAll(() => {
cmd = getRegistry().get('pinterest/board-create');
expect(cmd?.func).toBeTypeOf('function');
});

describe('pinterest board-create', () => {
it('throws ArgumentError on a blank name', async () => {
await expect(cmd.func(createPageMock([]), { name: ' ' })).rejects.toThrow(ArgumentError);
});

it('throws ArgumentError on an invalid privacy', async () => {
await expect(cmd.func(createPageMock([]), { name: 'My Board', privacy: 'hidden' })).rejects.toThrow(ArgumentError);
});

it('throws AuthRequiredError when unauthorized', async () => {
await expect(cmd.func(createPageMock([{ __httpError: 401 }]), { name: 'My Board' })).rejects.toThrow(AuthRequiredError);
});

it('creates the board and returns the row', async () => {
const result = await cmd.func(createPageMock([createdBoard('77')]), { name: 'My Board', privacy: 'public' });
expect(result).toEqual([{
boardId: '77',
name: 'My Board',
privacy: 'public',
url: 'https://www.pinterest.com/me/my-board/',
}]);
});

it('throws CommandExecutionError when create returns no board id', async () => {
await expect(cmd.func(createPageMock([createdBoard(null)]), { name: 'My Board' })).rejects.toThrow(CommandExecutionError);
});

it('surfaces Pinterest\'s own error message on HTTP failure', async () => {
const page = createPageMock([{ __httpError: 400, message: 'A board with that name already exists.' }]);
await expect(cmd.func(page, { name: 'My Board' })).rejects.toThrow('A board with that name already exists.');
});
});
42 changes: 42 additions & 0 deletions clis/pinterest/board-delete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Pinterest board-delete β€” delete one of your own boards (BoardResource/delete).
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { PINTEREST_BASE, resolveBoardTarget, pinterestResourceDelete, resolveBoardId } from './utils.js';

cli({
site: 'pinterest',
name: 'board-delete',
access: 'write',
description: 'Delete one of your own boards',
domain: 'www.pinterest.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'board', type: 'string', positional: true, required: true, help: '<username>/<slug>, a board URL, or a numeric board id, e.g. janedoe/my-board' },
{ name: 'confirm', type: 'bool', default: false, help: 'Actually delete β€” without it the board is only previewed' },
],
columns: ['boardId', 'name', 'pinCount', 'deleted'],
func: async (page, kwargs) => {
const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, kwargs.board);

await page.goto(`${PINTEREST_BASE}${path}`);
const { boardId, board } = await resolveBoardId(page, username, slug, path, preloadedBoard);

const row = {
boardId,
name: board.name || `${username}/${slug}`,
pinCount: typeof board.pin_count === 'number' ? board.pin_count : 0,
deleted: false,
};

// Deleting a board also destroys the pins saved in it, so require an explicit opt-in.
if (kwargs.confirm !== true) {
throw new ArgumentError(
`Refusing to delete board "${row.name}" (${row.pinCount} pins) without --confirm`,
'Re-run with --confirm once you are sure; deleting a board also deletes its pins',
);
}

await pinterestResourceDelete(page, 'BoardResource', { board_id: boardId }, path);
return [{ ...row, deleted: true }];
},
});
63 changes: 63 additions & 0 deletions clis/pinterest/board-delete.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { createPageMock } from '../test-utils.js';
import './board-delete.js';

let cmd;

function boardMeta(id) {
return { resource_response: { data: id ? { id, name: 'My Board', pin_count: 7 } : {} } };
}

const deleted = { resource_response: { data: null } };
const OK = { board: 'janedoe/my-board', confirm: true };

const boardById = {
resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } },
};

beforeAll(() => {
cmd = getRegistry().get('pinterest/board-delete');
expect(cmd?.func).toBeTypeOf('function');
});

describe('pinterest board-delete', () => {
it('throws ArgumentError on a board reference that is neither <username>/<slug> nor an id', async () => {
const page = createPageMock([]);
await expect(cmd.func(page, { ...OK, board: 'My Board' })).rejects.toThrow(/Not a board reference/);
expect(page.evaluate).not.toHaveBeenCalled(); // rejected without a lookup
});

it('resolves a numeric board id without re-fetching the board', async () => {
const page = createPageMock([boardById, boardById]);
await cmd.func(page, { ...OK, board: '720576077819585951' }).catch(() => {});
const lookup = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[0][0].match(/data=([^"&]+)/)[1]));
expect(lookup.options.board_id).toBe('720576077819585951');
// The board fetched above is reused, so no second BoardResource lookup goes out.
const followUp = page.evaluate.mock.calls[1]?.[0] ?? '';
expect(followUp).not.toContain('/resource/BoardResource/get/');
});

it('refuses to delete without --confirm', async () => {
const page = createPageMock([boardMeta('999')]);
await expect(cmd.func(page, { board: 'janedoe/my-board' })).rejects.toThrow(/--confirm/);
expect(page.evaluate).toHaveBeenCalledTimes(1); // resolved the board, never deleted
});

it('throws CommandExecutionError when the board cannot be resolved', async () => {
await expect(cmd.func(createPageMock([boardMeta(null)]), OK)).rejects.toThrow(CommandExecutionError);
});

it('throws AuthRequiredError when the delete is unauthorized', async () => {
const page = createPageMock([boardMeta('999'), { __httpError: 403 }]);
await expect(cmd.func(page, OK)).rejects.toThrow(AuthRequiredError);
});

it('deletes the board and reports the row', async () => {
const page = createPageMock([boardMeta('999'), deleted]);
const result = await cmd.func(page, OK);
expect(result).toEqual([{ boardId: '999', name: 'My Board', pinCount: 7, deleted: true }]);
expect(page.evaluate).toHaveBeenCalledTimes(2);
});
});
57 changes: 57 additions & 0 deletions clis/pinterest/board-pins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Pinterest board-pins β€” pins inside a board (BoardResource β†’ BoardFeedResource).
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { DEFAULT_PAGE_SIZE, PINTEREST_BASE, collectPins, resolveBoardTarget, pinterestResourceFetch, requireLimit } from './utils.js';

const DEFAULT_LIMIT = 25;
const MAX_LIMIT = 100;

cli({
site: 'pinterest',
name: 'board-pins',
access: 'read',
description: 'List pins inside a Pinterest board',
domain: 'www.pinterest.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'board', type: 'string', positional: true, required: true, help: '<username>/<slug>, a board URL, or a numeric board id, e.g. janedoe/my-board' },
{ name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of pins (max ${MAX_LIMIT})` },
],
columns: ['pinId', 'title', 'description', 'pinner', 'board', 'imageUrl', 'url'],
func: async (page, kwargs) => {
const limit = requireLimit(kwargs.limit, { fallback: DEFAULT_LIMIT, max: MAX_LIMIT });
const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, kwargs.board);

await page.goto(`${PINTEREST_BASE}${path}`);

// BoardFeedResource requires the numeric board id, which BoardResource resolves.
// A board id argument already fetched the board, so only look it up when addressed by slug.
const board = preloadedBoard
|| (await pinterestResourceFetch(page, 'BoardResource', { username, slug, field_set_key: 'detailed' }, path)).data;
const boardId = board && board.id;
if (!boardId) {
throw new CommandExecutionError(`Could not resolve board "${username}/${slug}" (does it exist and is it public?)`);
}

const rows = await collectPins(page, {
resource: 'BoardFeedResource',
baseOptions: {
board_id: String(boardId),
board_url: path,
currentFilter: -1,
field_set_key: 'react_grid_pin',
filter_section_pins: true,
sort: 'default',
layout: 'default',
},
sourceUrl: path,
limit,
pageSize: DEFAULT_PAGE_SIZE,
});

if (rows.length === 0) {
throw new EmptyResultError('pinterest board-pins', `no pins found in board "${username}/${slug}"`);
}
return rows;
},
});
84 changes: 84 additions & 0 deletions clis/pinterest/board-pins.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { createPageMock } from '../test-utils.js';
import './board-pins.js';

let cmd;

/** BoardResource response: the raw board object under resource_response.data. */
function boardMeta(id) {
return { resource_response: { data: id ? { id, name: 'My Board' } : {} } };
}

function feed(results, bookmark = null) {
return { resource_response: { data: { results }, bookmark } };
}

function pin(overrides = {}) {
return {
type: 'pin',
id: '111',
title: 'Wallpaper',
description: '',
pinner: { username: 'janedoe' },
board: { name: 'My Board' },
images: { orig: { url: 'https://i.pinimg.com/originals/a/b/c.jpg' } },
...overrides,
};
}

const boardById = {
resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } },
};

beforeAll(() => {
cmd = getRegistry().get('pinterest/board-pins');
expect(cmd?.func).toBeTypeOf('function');
});

describe('pinterest board-pins', () => {
it('throws ArgumentError on a board reference that is neither <username>/<slug> nor an id', async () => {
const page = createPageMock([]);
await expect(cmd.func(page, { board: 'My Board', limit: 5 })).rejects.toThrow(/Not a board reference/);
expect(page.evaluate).not.toHaveBeenCalled(); // rejected without a lookup
});

it('resolves a numeric board id without re-fetching the board', async () => {
const page = createPageMock([boardById, boardById]);
await cmd.func(page, { board: '720576077819585951', limit: 5 }).catch(() => {});
const lookup = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[0][0].match(/data=([^"&]+)/)[1]));
expect(lookup.options.board_id).toBe('720576077819585951');
// The board fetched above is reused, so no second BoardResource lookup goes out.
const followUp = page.evaluate.mock.calls[1]?.[0] ?? '';
expect(followUp).not.toContain('/resource/BoardResource/get/');
});

it('accepts full URL and username/slug forms', async () => {
for (const board of ['https://www.pinterest.com/janedoe/my-board/', 'janedoe/my-board']) {
const page = createPageMock([boardMeta('999'), feed([pin()])]);
const result = await cmd.func(page, { board, limit: 5 });
expect(result).toHaveLength(1);
}
});

it('throws CommandExecutionError when the board id cannot be resolved', async () => {
const page = createPageMock([boardMeta(null)]);
await expect(cmd.func(page, { board: 'janedoe/missing', limit: 5 })).rejects.toThrow(CommandExecutionError);
});

it('resolves the board id then maps feed pins, dropping ads', async () => {
const page = createPageMock([
boardMeta('999'),
feed([pin({ id: '1' }), pin({ id: 'ad', is_promoted: true }), pin({ id: '2' })]),
]);
const result = await cmd.func(page, { board: 'janedoe/my-board', limit: 10 });
expect(result.map((r) => r.pinId)).toEqual(['1', '2']);
expect(page.evaluate).toHaveBeenCalledTimes(2); // BoardResource + one feed page
});

it('throws EmptyResultError when the board has no pins', async () => {
const page = createPageMock([boardMeta('999'), feed([], null)]);
await expect(cmd.func(page, { board: 'janedoe/empty', limit: 5 })).rejects.toThrow(EmptyResultError);
});
});
46 changes: 46 additions & 0 deletions clis/pinterest/board-section-create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Pinterest board-section-create β€” add a section to one of your boards (BoardSectionResource/create).
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { PINTEREST_BASE, resolveBoardTarget, pinterestResourceCreate, resolveBoardId } from './utils.js';

cli({
site: 'pinterest',
name: 'board-section-create',
access: 'write',
description: 'Create a section inside one of your boards',
domain: 'www.pinterest.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'board', type: 'string', positional: true, required: true, help: '<username>/<slug>, a board URL, or a numeric board id, e.g. janedoe/my-board' },
{ name: 'title', type: 'string', required: true, help: 'Section title' },
],
columns: ['sectionId', 'title', 'slug', 'board', 'url'],
func: async (page, kwargs) => {
const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, kwargs.board);
const title = String(kwargs.title ?? '').trim();
if (!title) throw new ArgumentError('section title is required');

await page.goto(`${PINTEREST_BASE}${path}`);
const { boardId } = await resolveBoardId(page, username, slug, path, preloadedBoard);

// The section title goes in `name` here (a `title` key is rejected as a missing parameter).
const created = await pinterestResourceCreate(
page,
'BoardSectionResource',
{ board_id: boardId, name: title },
path,
);
const sectionId = created && created.id;
if (!sectionId) {
throw new CommandExecutionError('Section creation did not return a section id');
}

return [{
sectionId: String(sectionId),
title: created.title || created.name || title,
slug: created.slug || '',
board: `${username}/${slug}`,
url: created.slug ? `${PINTEREST_BASE}${path}${created.slug}/` : '',
}];
},
});
Loading