diff --git a/cli-manifest.json b/cli-manifest.json index 8706bdd3a..4c52498f6 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -27095,6 +27095,787 @@ "modulePath": "paperreview/submit.js", "sourceFile": "paperreview/submit.js" }, + { + "site": "pinterest", + "name": "board-create", + "description": "Create a new board on your account", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "positional": true, + "help": "Board name" + }, + { + "name": "description", + "type": "string", + "default": "", + "required": false, + "help": "Board description" + }, + { + "name": "privacy", + "type": "string", + "default": "public", + "required": false, + "help": "public or secret", + "choices": [ + "public", + "secret" + ] + } + ], + "columns": [ + "boardId", + "name", + "privacy", + "url" + ], + "type": "js", + "modulePath": "pinterest/board-create.js", + "sourceFile": "pinterest/board-create.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "board-delete", + "description": "Delete one of your own boards", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "board", + "type": "string", + "required": true, + "positional": true, + "help": "/, a board URL, or a numeric board id, e.g. janedoe/my-board" + }, + { + "name": "confirm", + "type": "bool", + "default": false, + "required": false, + "help": "Actually delete — without it the board is only previewed" + } + ], + "columns": [ + "boardId", + "name", + "pinCount", + "deleted" + ], + "type": "js", + "modulePath": "pinterest/board-delete.js", + "sourceFile": "pinterest/board-delete.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "board-pins", + "description": "List pins inside a Pinterest board", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "board", + "type": "string", + "required": true, + "positional": true, + "help": "/, a board URL, or a numeric board id, e.g. janedoe/my-board" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of pins (max 100)" + } + ], + "columns": [ + "pinId", + "title", + "description", + "pinner", + "board", + "imageUrl", + "url" + ], + "type": "js", + "modulePath": "pinterest/board-pins.js", + "sourceFile": "pinterest/board-pins.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "board-section-create", + "description": "Create a section inside one of your boards", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "board", + "type": "string", + "required": true, + "positional": true, + "help": "/, 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" + ], + "type": "js", + "modulePath": "pinterest/board-section-create.js", + "sourceFile": "pinterest/board-section-create.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "board-section-delete", + "description": "Delete a section from one of your boards", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "board", + "type": "string", + "required": true, + "positional": true, + "help": "/, a board URL, or a numeric board id, e.g. janedoe/my-board" + }, + { + "name": "section", + "type": "string", + "required": true, + "help": "Section id or slug (from `board-sections`)" + }, + { + "name": "confirm", + "type": "bool", + "default": false, + "required": false, + "help": "Actually delete — without it nothing is removed" + } + ], + "columns": [ + "sectionId", + "board", + "deleted" + ], + "type": "js", + "modulePath": "pinterest/board-section-delete.js", + "sourceFile": "pinterest/board-section-delete.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "board-sections", + "description": "List the sections inside a Pinterest board", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "board", + "type": "string", + "required": true, + "positional": true, + "help": "/, a board URL, or a numeric board id, e.g. janedoe/my-board" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "Number of sections (max 250)" + } + ], + "columns": [ + "sectionId", + "title", + "slug", + "pinCount", + "url" + ], + "type": "js", + "modulePath": "pinterest/board-sections.js", + "sourceFile": "pinterest/board-sections.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "board-update", + "description": "Update the name, description, or privacy of your board", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "board", + "type": "string", + "required": true, + "positional": true, + "help": "/, a board URL, or a numeric board id, e.g. janedoe/my-board" + }, + { + "name": "name", + "type": "string", + "default": "", + "required": false, + "help": "New board name" + }, + { + "name": "description", + "type": "string", + "required": false, + "help": "New board description (pass \"\" to clear)" + }, + { + "name": "privacy", + "type": "string", + "required": false, + "help": "New privacy: public or secret", + "choices": [ + "public", + "secret" + ] + } + ], + "columns": [ + "boardId", + "name", + "privacy", + "url" + ], + "type": "js", + "modulePath": "pinterest/board-update.js", + "sourceFile": "pinterest/board-update.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "download", + "description": "Download a pin's original image to disk", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "pin", + "type": "string", + "required": true, + "positional": true, + "help": "Pin id or pin URL, e.g. 1234567890123456" + }, + { + "name": "output", + "type": "string", + "default": "./pinterest-downloads", + "required": false, + "help": "Output directory" + } + ], + "columns": [ + "pinId", + "status", + "size", + "path" + ], + "type": "js", + "modulePath": "pinterest/download.js", + "sourceFile": "pinterest/download.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "pin", + "description": "Get details of a Pinterest pin", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "pin", + "type": "string", + "required": true, + "positional": true, + "help": "Pin id or pin URL, e.g. 1234567890123456" + } + ], + "columns": [ + "pinId", + "title", + "description", + "pinner", + "board", + "saveCount", + "commentCount", + "link", + "imageUrl", + "url" + ], + "type": "js", + "modulePath": "pinterest/pin.js", + "sourceFile": "pinterest/pin.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "pin-create", + "description": "Create a pin from a remote image URL onto a board", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "image", + "type": "string", + "required": true, + "positional": true, + "help": "Direct image URL Pinterest can fetch (not a page URL), e.g. https://example.com/image.jpg" + }, + { + "name": "board", + "type": "string", + "required": true, + "help": "Target board: /, board URL, or board id (a new pin always needs a board)" + }, + { + "name": "section", + "type": "string", + "default": "", + "required": false, + "help": "Optional board section id or slug" + }, + { + "name": "title", + "type": "string", + "default": "", + "required": false, + "help": "Pin title" + }, + { + "name": "description", + "type": "string", + "default": "", + "required": false, + "help": "Pin description (Pinterest may override it for scraped images)" + }, + { + "name": "link", + "type": "string", + "default": "", + "required": false, + "help": "Destination link for the pin" + } + ], + "columns": [ + "pinId", + "board", + "title", + "url" + ], + "type": "js", + "modulePath": "pinterest/pin-create.js", + "sourceFile": "pinterest/pin-create.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "pin-delete", + "description": "Delete one of your own pins", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "pin", + "type": "string", + "required": true, + "positional": true, + "help": "Pin id or pin URL, e.g. 1234567890123456" + }, + { + "name": "confirm", + "type": "bool", + "default": false, + "required": false, + "help": "Actually delete — without it the pin is only previewed" + } + ], + "columns": [ + "pinId", + "title", + "board", + "deleted" + ], + "type": "js", + "modulePath": "pinterest/pin-delete.js", + "sourceFile": "pinterest/pin-delete.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "pin-update", + "description": "Update a pin's title, description, link, or board", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "pin", + "type": "string", + "required": true, + "positional": true, + "help": "Pin id or pin URL, e.g. 1234567890123456" + }, + { + "name": "title", + "type": "string", + "default": "", + "required": false, + "help": "New pin title" + }, + { + "name": "description", + "type": "string", + "required": false, + "help": "New pin description (pass \"\" to clear)" + }, + { + "name": "link", + "type": "string", + "required": false, + "help": "New destination link (pass \"\" to clear)" + }, + { + "name": "board", + "type": "string", + "default": "", + "required": false, + "help": "Move the pin to this board: /, board URL, or board id" + }, + { + "name": "section", + "type": "string", + "default": "", + "required": false, + "help": "Move the pin to this board section id or slug (requires --board)" + } + ], + "columns": [ + "pinId", + "title", + "board", + "url" + ], + "type": "js", + "modulePath": "pinterest/pin-update.js", + "sourceFile": "pinterest/pin-update.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "save", + "description": "Save a pin to your profile or a board", + "access": "write", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "pin", + "type": "string", + "required": true, + "positional": true, + "help": "Pin id or pin URL to repin, e.g. 1234567890123456" + }, + { + "name": "board", + "type": "string", + "required": false, + "help": "Target board: /, board URL, or board id (omit to save to your profile)" + }, + { + "name": "section", + "type": "string", + "default": "", + "required": false, + "help": "Optional board section id or slug (requires --board)" + } + ], + "columns": [ + "pinId", + "sourcePinId", + "board", + "url" + ], + "type": "js", + "modulePath": "pinterest/save.js", + "sourceFile": "pinterest/save.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "search-boards", + "description": "Search for boards on Pinterest", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of boards (max 100)" + } + ], + "columns": [ + "boardId", + "name", + "pinCount", + "owner", + "description", + "url" + ], + "type": "js", + "modulePath": "pinterest/search-boards.js", + "sourceFile": "pinterest/search-boards.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "search-pins", + "description": "Search pins on Pinterest", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of pins (max 100)" + } + ], + "columns": [ + "pinId", + "title", + "description", + "pinner", + "board", + "imageUrl", + "url" + ], + "type": "js", + "modulePath": "pinterest/search-pins.js", + "sourceFile": "pinterest/search-pins.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "search-users", + "description": "Search for users on Pinterest", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of users (max 100)" + } + ], + "columns": [ + "username", + "fullName", + "followerCount", + "pinCount", + "url" + ], + "type": "js", + "modulePath": "pinterest/search-users.js", + "sourceFile": "pinterest/search-users.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "user", + "description": "Get a Pinterest user's public profile stats", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Username or profile URL, e.g. janedoe" + } + ], + "columns": [ + "username", + "fullName", + "followerCount", + "followingCount", + "interestFollowingCount", + "pinCount", + "boardCount", + "about", + "website", + "url" + ], + "type": "js", + "modulePath": "pinterest/user.js", + "sourceFile": "pinterest/user.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "user-boards", + "description": "List a user's boards", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Username or profile URL, e.g. janedoe" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of boards (max 250)" + }, + { + "name": "sort", + "type": "string", + "default": "last_pinned_to", + "required": false, + "help": "Board sort order", + "choices": [ + "last_pinned_to", + "alphabetical", + "custom" + ] + } + ], + "columns": [ + "boardId", + "name", + "pinCount", + "sectionCount", + "privacy", + "url" + ], + "type": "js", + "modulePath": "pinterest/user-boards.js", + "sourceFile": "pinterest/user-boards.js", + "navigateBefore": "https://www.pinterest.com" + }, + { + "site": "pinterest", + "name": "user-pins", + "description": "List pins created by a Pinterest user", + "access": "read", + "domain": "www.pinterest.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "username", + "type": "string", + "required": true, + "positional": true, + "help": "Username or profile URL, e.g. janedoe" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Number of pins (max 100)" + } + ], + "columns": [ + "pinId", + "title", + "description", + "pinner", + "board", + "imageUrl", + "url" + ], + "type": "js", + "modulePath": "pinterest/user-pins.js", + "sourceFile": "pinterest/user-pins.js", + "navigateBefore": "https://www.pinterest.com" + }, { "site": "pixiv", "name": "detail", diff --git a/clis/pinterest/board-create.js b/clis/pinterest/board-create.js new file mode 100644 index 000000000..9337c5767 --- /dev/null +++ b/clis/pinterest/board-create.js @@ -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}` : '', + }]; + }, +}); diff --git a/clis/pinterest/board-create.test.js b/clis/pinterest/board-create.test.js new file mode 100644 index 000000000..ebcd41fab --- /dev/null +++ b/clis/pinterest/board-create.test.js @@ -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.'); + }); +}); diff --git a/clis/pinterest/board-delete.js b/clis/pinterest/board-delete.js new file mode 100644 index 000000000..cf1419239 --- /dev/null +++ b/clis/pinterest/board-delete.js @@ -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: '/, 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 }]; + }, +}); diff --git a/clis/pinterest/board-delete.test.js b/clis/pinterest/board-delete.test.js new file mode 100644 index 000000000..2cb12f3f4 --- /dev/null +++ b/clis/pinterest/board-delete.test.js @@ -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 / 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); + }); +}); diff --git a/clis/pinterest/board-pins.js b/clis/pinterest/board-pins.js new file mode 100644 index 000000000..abc46523f --- /dev/null +++ b/clis/pinterest/board-pins.js @@ -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: '/, 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; + }, +}); diff --git a/clis/pinterest/board-pins.test.js b/clis/pinterest/board-pins.test.js new file mode 100644 index 000000000..562b2ea11 --- /dev/null +++ b/clis/pinterest/board-pins.test.js @@ -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 / 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); + }); +}); diff --git a/clis/pinterest/board-section-create.js b/clis/pinterest/board-section-create.js new file mode 100644 index 000000000..103dba91a --- /dev/null +++ b/clis/pinterest/board-section-create.js @@ -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: '/, 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}/` : '', + }]; + }, +}); diff --git a/clis/pinterest/board-section-create.test.js b/clis/pinterest/board-section-create.test.js new file mode 100644 index 000000000..be4455ea0 --- /dev/null +++ b/clis/pinterest/board-section-create.test.js @@ -0,0 +1,74 @@ +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-section-create.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id, name: 'My Board' } : {} } }; +} + +function created(id) { + return { resource_response: { data: id ? { id, title: 'My Section', slug: 'my-section' } : {} } }; +} + +const OK = { board: 'janedoe/my-board', title: 'My Section' }; + +const boardById = { + resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/board-section-create'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest board-section-create', () => { + it('throws ArgumentError on a board reference that is neither / 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('throws ArgumentError on a blank title', async () => { + await expect(cmd.func(createPageMock([]), { ...OK, title: ' ' })).rejects.toThrow(ArgumentError); + }); + + 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 create is unauthorized', async () => { + const page = createPageMock([boardMeta('999'), { __httpError: 403 }]); + await expect(cmd.func(page, OK)).rejects.toThrow(AuthRequiredError); + }); + + it('creates the section and returns its row', async () => { + const page = createPageMock([boardMeta('999'), created('42')]); + const result = await cmd.func(page, OK); + expect(result).toEqual([{ + sectionId: '42', + title: 'My Section', + slug: 'my-section', + board: 'janedoe/my-board', + url: 'https://www.pinterest.com/janedoe/my-board/my-section/', + }]); + }); + + it('throws CommandExecutionError when create returns no section id', async () => { + const page = createPageMock([boardMeta('999'), created(null)]); + await expect(cmd.func(page, OK)).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/pinterest/board-section-delete.js b/clis/pinterest/board-section-delete.js new file mode 100644 index 000000000..4188806bc --- /dev/null +++ b/clis/pinterest/board-section-delete.js @@ -0,0 +1,49 @@ +// Pinterest board-section-delete — remove a section from one of your boards (ApiResource/delete → /v3/board/sections/). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, resolveBoardTarget, pinterestResourceFetch, resolveBoardId, resolveSection } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'board-section-delete', + access: 'write', + description: 'Delete a section from one of your boards', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'board', type: 'string', positional: true, required: true, help: '/, a board URL, or a numeric board id, e.g. janedoe/my-board' }, + { name: 'section', type: 'string', required: true, help: 'Section id or slug (from `board-sections`)' }, + { name: 'confirm', type: 'bool', default: false, help: 'Actually delete — without it nothing is removed' }, + ], + columns: ['sectionId', 'board', 'deleted'], + func: async (page, kwargs) => { + const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, kwargs.board); + const section = String(kwargs.section ?? '').trim(); + if (!section) throw new ArgumentError('section is required', 'List sections with `pinterest board-sections `'); + + await page.goto(`${PINTEREST_BASE}${path}`); + const { boardId } = await resolveBoardId(page, username, slug, path, preloadedBoard); + // Resolve before the gate so the preview names the section that would actually be deleted. + const { sectionId, title } = await resolveSection(page, boardId, section, path); + + // Deleting a section returns its pins to the board rather than destroying them, + // but it is still not undoable, so keep the same --confirm gate as the other deletes. + if (kwargs.confirm !== true) { + throw new ArgumentError( + `Refusing to delete section "${title || sectionId}" without --confirm`, + 'Re-run with --confirm once you are sure', + ); + } + + // BoardSectionResource only implements `create`; deletes go through the v3 API proxy. + await pinterestResourceFetch( + page, + 'ApiResource', + { url: `/v3/board/sections/${sectionId}/`, data: {} }, + path, + 'delete', + ); + + return [{ sectionId, board: `${username}/${slug}`, deleted: true }]; + }, +}); diff --git a/clis/pinterest/board-section-delete.test.js b/clis/pinterest/board-section-delete.test.js new file mode 100644 index 000000000..d880116fe --- /dev/null +++ b/clis/pinterest/board-section-delete.test.js @@ -0,0 +1,105 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './board-section-delete.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id, name: 'My Board' } : {} } }; +} + +const sections = { + resource_response: { + data: [ + { id: '42', title: 'Cats', slug: 'cats', pin_count: 3 }, + { id: '43', title: 'Dogs', slug: 'dogs', pin_count: 1 }, + { id: '44', title: 'Big Cats', slug: 'big-cats', pin_count: 0 }, + ], + }, +}; + +const deleted = { resource_response: { data: null } }; +const OK = { board: 'janedoe/my-board', section: '42', confirm: true }; + +const boardById = { + resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/board-section-delete'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest board-section-delete', () => { + it('throws ArgumentError on a board reference that is neither / 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('throws ArgumentError on a blank section', async () => { + await expect(cmd.func(createPageMock([]), { ...OK, section: ' ' })).rejects.toThrow(ArgumentError); + }); + + it('throws ArgumentError listing the real sections when nothing matches', async () => { + const page = createPageMock([boardMeta('999'), sections]); + await expect(cmd.func(page, { ...OK, section: 'birds' })).rejects.toThrow(/No section matching "birds"/); + expect(page.evaluate).toHaveBeenCalledTimes(2); // resolved, never deleted + }); + + it('accepts a section slug', async () => { + const page = createPageMock([boardMeta('999'), sections, deleted]); + const result = await cmd.func(page, { ...OK, section: 'dogs' }); + expect(result[0].sectionId).toBe('43'); + }); + + it('accepts a section slug case-insensitively', async () => { + const page = createPageMock([boardMeta('999'), sections, deleted]); + const result = await cmd.func(page, { ...OK, section: 'CATS' }); + expect(result[0].sectionId).toBe('42'); + }); + + it('does not match a section by its display title', async () => { + const page = createPageMock([boardMeta('999'), sections]); + await expect(cmd.func(page, { ...OK, section: 'Big Cats' })).rejects.toThrow(/No section matching "Big Cats"/); + }); + + it('refuses to delete without --confirm, naming the resolved section', async () => { + const page = createPageMock([boardMeta('999'), sections]); + await expect(cmd.func(page, { board: 'janedoe/my-board', section: '42' })).rejects.toThrow(/Cats.*--confirm/); + expect(page.evaluate).toHaveBeenCalledTimes(2); // board + sections lookup only, no delete + }); + + it('throws AuthRequiredError when the delete is unauthorized', async () => { + const page = createPageMock([boardMeta('999'), sections, { __httpError: 403 }]); + await expect(cmd.func(page, OK)).rejects.toThrow(AuthRequiredError); + }); + + it('deletes the section and reports the row', async () => { + const page = createPageMock([boardMeta('999'), sections, deleted]); + const result = await cmd.func(page, OK); + expect(result).toEqual([{ sectionId: '42', board: 'janedoe/my-board', deleted: true }]); + expect(page.evaluate).toHaveBeenCalledTimes(3); + }); + + it('deletes via the v3 API proxy (BoardSectionResource has no delete)', async () => { + const page = createPageMock([boardMeta('999'), sections, deleted]); + await cmd.func(page, OK); + const script = page.evaluate.mock.calls[2][0]; + expect(script).toContain('/resource/ApiResource/delete/'); + const sent = JSON.parse(decodeURIComponent(script.match(/data=([^"&]+)/)[1])); + expect(sent.options.url).toBe('/v3/board/sections/42/'); + }); +}); diff --git a/clis/pinterest/board-sections.js b/clis/pinterest/board-sections.js new file mode 100644 index 000000000..81931c1e0 --- /dev/null +++ b/clis/pinterest/board-sections.js @@ -0,0 +1,58 @@ +// Pinterest board-sections — sections inside a board (BoardResource → BoardSectionsResource). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, resolveBoardTarget, pinterestResourceFetch, requireLimit } from './utils.js'; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 250; + +cli({ + site: 'pinterest', + name: 'board-sections', + access: 'read', + description: 'List the sections inside a Pinterest board', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'board', type: 'string', positional: true, required: true, help: '/, a board URL, or a numeric board id, e.g. janedoe/my-board' }, + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of sections (max ${MAX_LIMIT})` }, + ], + columns: ['sectionId', 'title', 'slug', 'pinCount', '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}`); + + // 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 { results } = await pinterestResourceFetch( + page, + 'BoardSectionsResource', + { board_id: String(boardId) }, + path, + ); + + const rows = results + .filter((section) => section && section.id) + .slice(0, limit) + .map((section) => ({ + sectionId: String(section.id), + title: (section.title || '').trim(), + slug: section.slug || '', + pinCount: typeof section.pin_count === 'number' ? section.pin_count : 0, + url: section.slug ? `${PINTEREST_BASE}${path}${section.slug}/` : '', + })); + + if (rows.length === 0) { + throw new EmptyResultError('pinterest board-sections', `board "${username}/${slug}" has no sections`); + } + return rows; + }, +}); diff --git a/clis/pinterest/board-sections.test.js b/clis/pinterest/board-sections.test.js new file mode 100644 index 000000000..39c22d437 --- /dev/null +++ b/clis/pinterest/board-sections.test.js @@ -0,0 +1,68 @@ +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-sections.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id } : {} } }; +} + +function sections(list) { + return { resource_response: { data: list } }; +} + +function section(overrides = {}) { + return { id: '9876543210987654', title: 'My Section', slug: 'my-section', pin_count: 6, ...overrides }; +} + +const boardById = { + resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/board-sections'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest board-sections', () => { + it('throws ArgumentError on a board reference that is neither / nor an id', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { 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, { 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('throws CommandExecutionError when the board id cannot be resolved', async () => { + await expect(cmd.func(createPageMock([boardMeta(null)]), { board: 'janedoe/missing' })).rejects.toThrow(CommandExecutionError); + }); + + it('resolves the board then maps sections', async () => { + const page = createPageMock([boardMeta('999'), sections([section(), section({ id: '2', title: 'Fall', slug: 'fall', pin_count: 37 })])]); + const result = await cmd.func(page, { board: 'janedoe/my-board', limit: 10 }); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + sectionId: '9876543210987654', + title: 'My Section', + slug: 'my-section', + pinCount: 6, + url: 'https://www.pinterest.com/janedoe/my-board/my-section/', + }); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('throws EmptyResultError when the board has no sections', async () => { + await expect(cmd.func(createPageMock([boardMeta('999'), sections([])]), { board: 'janedoe/plain' })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/board-update.js b/clis/pinterest/board-update.js new file mode 100644 index 000000000..d17cceb65 --- /dev/null +++ b/clis/pinterest/board-update.js @@ -0,0 +1,55 @@ +// Pinterest board-update — rename or re-configure one of your boards (BoardResource/update). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, resolveBoardTarget, pinterestResourceUpdate, resolveBoardId } from './utils.js'; + +const PRIVACY = ['public', 'secret']; + +cli({ + site: 'pinterest', + name: 'board-update', + access: 'write', + description: 'Update the name, description, or privacy of your board', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'board', type: 'string', positional: true, required: true, help: '/, a board URL, or a numeric board id, e.g. janedoe/my-board' }, + { name: 'name', type: 'string', default: '', help: 'New board name' }, + { name: 'description', type: 'string', help: 'New board description (pass "" to clear)' }, + { name: 'privacy', type: 'string', choices: PRIVACY, help: 'New privacy: public or secret' }, + ], + columns: ['boardId', 'name', 'privacy', 'url'], + func: async (page, kwargs) => { + const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, kwargs.board); + const name = String(kwargs.name ?? '').trim(); + const description = kwargs.description === undefined ? undefined : String(kwargs.description).trim(); + const privacy = String(kwargs.privacy ?? '').trim(); + + if (privacy && !PRIVACY.includes(privacy)) { + throw new ArgumentError(`Unknown privacy "${privacy}". Valid: ${PRIVACY.join(', ')}`); + } + if (!name && description === undefined && !privacy) { + throw new ArgumentError( + 'nothing to update', + 'Pass at least one of --name, --description, or --privacy', + ); + } + + await page.goto(`${PINTEREST_BASE}${path}`); + const { boardId } = await resolveBoardId(page, username, slug, path, preloadedBoard); + + const options = { board_id: boardId }; + if (name) options.name = name; + if (description !== undefined) options.description = description; + if (privacy) options.privacy = privacy; + + const updated = await pinterestResourceUpdate(page, 'BoardResource', options, path); + + return [{ + boardId, + name: (updated && updated.name) || name, + privacy: (updated && updated.privacy) || privacy, + url: updated && updated.url ? `${PINTEREST_BASE}${updated.url}` : `${PINTEREST_BASE}${path}`, + }]; + }, +}); diff --git a/clis/pinterest/board-update.test.js b/clis/pinterest/board-update.test.js new file mode 100644 index 000000000..70799b1b1 --- /dev/null +++ b/clis/pinterest/board-update.test.js @@ -0,0 +1,78 @@ +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-update.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id, name: 'Old Name' } : {} } }; +} + +function updated(overrides = {}) { + return { resource_response: { data: { id: '999', name: 'New Name', privacy: 'secret', url: '/janedoe/new-name/', ...overrides } } }; +} + +const boardById = { + resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/board-update'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest board-update', () => { + it('throws ArgumentError on a board reference that is neither / nor an id', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { board: 'My Board', name: 'x' })).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', name: 'x' }).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('throws ArgumentError when no field is given', async () => { + await expect(cmd.func(createPageMock([]), { board: 'janedoe/my-board' })).rejects.toThrow(/nothing to update/); + }); + + it('throws ArgumentError on an unknown privacy value', async () => { + await expect(cmd.func(createPageMock([]), { board: 'janedoe/my-board', privacy: 'hidden' })).rejects.toThrow(ArgumentError); + }); + + it('throws CommandExecutionError when the board cannot be resolved', async () => { + const page = createPageMock([boardMeta(null)]); + await expect(cmd.func(page, { board: 'janedoe/my-board', name: 'New Name' })).rejects.toThrow(CommandExecutionError); + }); + + it('throws AuthRequiredError when the update is unauthorized', async () => { + const page = createPageMock([boardMeta('999'), { __httpError: 403 }]); + await expect(cmd.func(page, { board: 'janedoe/my-board', name: 'New Name' })).rejects.toThrow(AuthRequiredError); + }); + + it('sends only the fields that were passed', async () => { + const page = createPageMock([boardMeta('999'), updated()]); + await cmd.func(page, { board: 'janedoe/my-board', name: 'New Name', privacy: 'secret' }); + const sent = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[1][0].match(/data=([^"&]+)/)[1])); + expect(sent.options).toEqual({ board_id: '999', name: 'New Name', privacy: 'secret' }); + }); + + it('returns the updated row', async () => { + const page = createPageMock([boardMeta('999'), updated()]); + const result = await cmd.func(page, { board: 'janedoe/my-board', name: 'New Name' }); + expect(result).toEqual([{ + boardId: '999', + name: 'New Name', + privacy: 'secret', + url: 'https://www.pinterest.com/janedoe/new-name/', + }]); + }); +}); diff --git a/clis/pinterest/download.js b/clis/pinterest/download.js new file mode 100644 index 000000000..9942eed16 --- /dev/null +++ b/clis/pinterest/download.js @@ -0,0 +1,64 @@ +// Pinterest download — save a pin's original image to disk (PinResource + httpDownload). +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { httpDownload } from '@jackwener/opencli/download'; +import { formatBytes } from '@jackwener/opencli/download/progress'; +import { CommandExecutionError, EmptyResultError, getErrorMessage } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, parsePinId, pickPinImage, pinterestResourceFetch } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'download', + access: 'read', + description: 'Download a pin\'s original image to disk', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'pin', type: 'string', positional: true, required: true, help: 'Pin id or pin URL, e.g. 1234567890123456' }, + { name: 'output', type: 'string', default: './pinterest-downloads', help: 'Output directory' }, + ], + columns: ['pinId', 'status', 'size', 'path'], + func: async (page, kwargs) => { + const id = parsePinId(kwargs.pin); + const output = String(kwargs.output ?? './pinterest-downloads'); + + const sourceUrl = `/pin/${id}/`; + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const { data: pin } = await pinterestResourceFetch( + page, + 'PinResource', + { id, field_set_key: 'detailed' }, + sourceUrl, + ); + if (!pin || !pin.id) { + throw new EmptyResultError('pinterest download', `pin "${id}" not found`); + } + const imageUrl = pickPinImage(pin.images); + if (!imageUrl) { + throw new CommandExecutionError(`Pin ${id} has no downloadable image (it may be a video or story pin)`); + } + + fs.mkdirSync(output, { recursive: true }); + const ext = path.extname(new URL(imageUrl).pathname) || '.jpg'; + const destPath = path.join(output, `${id}${ext}`); + + let result; + try { + result = await httpDownload(imageUrl, destPath, { timeout: 60000 }); + } catch (err) { + throw new CommandExecutionError(`Failed to download pin ${id}: ${getErrorMessage(err)}`); + } + if (!result.success) { + throw new CommandExecutionError(`Failed to download pin ${id}: ${result.error || 'unknown error'}`); + } + + return [{ + pinId: id, + status: 'success', + size: formatBytes(result.size), + path: destPath, + }]; + }, +}); diff --git a/clis/pinterest/download.test.js b/clis/pinterest/download.test.js new file mode 100644 index 000000000..2c796fad5 --- /dev/null +++ b/clis/pinterest/download.test.js @@ -0,0 +1,67 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; + +// Mock download + fs dependencies before importing the adapter. +const { mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({ + mockHttpDownload: vi.fn(), + mockMkdirSync: vi.fn(), +})); +vi.mock('@jackwener/opencli/download', () => ({ httpDownload: mockHttpDownload })); +vi.mock('@jackwener/opencli/download/progress', () => ({ formatBytes: (n) => `${n}B` })); +vi.mock('node:fs', () => ({ mkdirSync: mockMkdirSync })); + +await import('./download.js'); + +let cmd; + +function pinDetail(overrides = {}) { + return { + resource_response: { + data: { id: '1234567890123456', images: { orig: { url: 'https://i.pinimg.com/originals/a/b/c.png' } }, ...overrides }, + }, + }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/download'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest download', () => { + beforeEach(() => { + mockHttpDownload.mockReset(); + mockMkdirSync.mockReset(); + }); + + it('throws ArgumentError on a non-pin reference', async () => { + await expect(cmd.func(createPageMock([]), { pin: 'nope', output: '/tmp/x' })).rejects.toThrow(ArgumentError); + }); + + it('throws EmptyResultError when the pin is missing', async () => { + const page = createPageMock([{ resource_response: { data: {} } }]); + await expect(cmd.func(page, { pin: '123', output: '/tmp/x' })).rejects.toThrow(EmptyResultError); + }); + + it('throws CommandExecutionError when the pin has no image', async () => { + const page = createPageMock([pinDetail({ images: {} })]); + await expect(cmd.func(page, { pin: '123', output: '/tmp/x' })).rejects.toThrow(CommandExecutionError); + }); + + it('downloads the original image and returns a success row', async () => { + mockHttpDownload.mockResolvedValue({ success: true, size: 2048 }); + const page = createPageMock([pinDetail()]); + const result = await cmd.func(page, { pin: 'https://www.pinterest.com/pin/1234567890123456/', output: '/tmp/x' }); + expect(result[0]).toMatchObject({ pinId: '1234567890123456', status: 'success', size: '2048B' }); + expect(result[0].path).toMatch(/1234567890123456\.png$/); + expect(mockHttpDownload).toHaveBeenCalledTimes(1); + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/x', { recursive: true }); + }); + + it('throws CommandExecutionError when the download fails', async () => { + mockHttpDownload.mockResolvedValue({ success: false, error: 'network' }); + const page = createPageMock([pinDetail()]); + await expect(cmd.func(page, { pin: '1234567890123456', output: '/tmp/x' })).rejects.toThrow(CommandExecutionError); + }); +}); diff --git a/clis/pinterest/pin-create.js b/clis/pinterest/pin-create.js new file mode 100644 index 000000000..8e65ac2a5 --- /dev/null +++ b/clis/pinterest/pin-create.js @@ -0,0 +1,76 @@ +// Pinterest pin-create — create a pin from a remote image URL onto a board (PinResource/create). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, movePinToSection, pinterestResourceCreate, resolveBoardId, resolveBoardTarget, resolveSection } from './utils.js'; + +function requireImageUrl(raw) { + const value = String(raw ?? '').trim(); + if (!value) throw new ArgumentError('image is required (a remote image URL)'); + let parsed; + try { + parsed = new URL(value); + } catch { + throw new ArgumentError(`image must be a remote image URL (got "${raw}")`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new ArgumentError(`image URL must be http(s): ${value}`); + } + return value; +} + +cli({ + site: 'pinterest', + name: 'pin-create', + access: 'write', + description: 'Create a pin from a remote image URL onto a board', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'image', type: 'string', positional: true, required: true, help: 'Direct image URL Pinterest can fetch (not a page URL), e.g. https://example.com/image.jpg' }, + { name: 'board', type: 'string', required: true, help: 'Target board: /, board URL, or board id (a new pin always needs a board)' }, + { name: 'section', type: 'string', default: '', help: 'Optional board section id or slug' }, + { name: 'title', type: 'string', default: '', help: 'Pin title' }, + // Pinterest ignores description for scraped pins (it derives one from the source page). + { name: 'description', type: 'string', default: '', help: 'Pin description (Pinterest may override it for scraped images)' }, + { name: 'link', type: 'string', default: '', help: 'Destination link for the pin' }, + ], + columns: ['pinId', 'board', 'title', 'url'], + func: async (page, kwargs) => { + const imageUrl = requireImageUrl(kwargs.image); + const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, kwargs.board); + const section = String(kwargs.section ?? '').trim(); + const title = String(kwargs.title ?? '').trim(); + const description = String(kwargs.description ?? '').trim(); + const link = String(kwargs.link ?? '').trim(); + + await page.goto(`${PINTEREST_BASE}${path}`); + + // Resolve the numeric board id the create call needs. + const { boardId } = await resolveBoardId(page, username, slug, path, preloadedBoard); + + // Validate --section before creating, so a bad value fails before a pin exists. + const sectionId = section ? (await resolveSection(page, boardId, section, path)).sectionId : ''; + + const options = { board_id: boardId, image_url: imageUrl, method: 'scraped' }; + if (title) options.title = title; + if (description) options.description = description; + if (link) options.link = link; + + const created = await pinterestResourceCreate(page, 'PinResource', options, path); + const newId = created && created.id; + if (!newId) { + throw new CommandExecutionError('Pin creation did not return a pin id'); + } + + if (sectionId) { + await movePinToSection(page, newId, boardId, sectionId, path); + } + + return [{ + pinId: String(newId), + board: (created.board && created.board.name) || `${username}/${slug}`, + title: (created.title || created.grid_title || title || '').trim(), + url: `${PINTEREST_BASE}/pin/${newId}/`, + }]; + }, +}); diff --git a/clis/pinterest/pin-create.test.js b/clis/pinterest/pin-create.test.js new file mode 100644 index 000000000..ade37586e --- /dev/null +++ b/clis/pinterest/pin-create.test.js @@ -0,0 +1,106 @@ +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 './pin-create.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id, name: 'Home' } : {} } }; +} + +function created(id) { + return { resource_response: { data: id ? { id, title: 'My Pin', board: { name: 'Home' } } : {} } }; +} + +const OK = { image: 'https://example.com/a.jpg', board: 'me/home' }; + +const sections = { + resource_response: { + data: [ + { id: 'sec-1', title: 'Cats', slug: 'cats', pin_count: 2 }, + { id: 'sec-2', title: 'Dogs', slug: 'dogs', pin_count: 0 }, + ], + }, +}; + +const updated = { resource_response: { data: { id: '42' } } }; + +const boardById = { + resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/pin-create'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest pin-create', () => { + it('rejects a local file path (URL required)', async () => { + await expect(cmd.func(createPageMock([]), { ...OK, image: './photo.jpg' })).rejects.toThrow(ArgumentError); + }); + + it('throws ArgumentError on a board reference that is neither / 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('throws AuthRequiredError when the create call is unauthorized', async () => { + const page = createPageMock([boardMeta('999'), { __httpError: 401 }]); + await expect(cmd.func(page, OK)).rejects.toThrow(AuthRequiredError); + }); + + it('resolves the board then creates the pin and returns its row', async () => { + const page = createPageMock([boardMeta('999'), created('42')]); + const result = await cmd.func(page, { ...OK, title: 'My Pin' }); + expect(result).toEqual([{ + pinId: '42', + board: 'Home', + title: 'My Pin', + url: 'https://www.pinterest.com/pin/42/', + }]); + expect(page.evaluate).toHaveBeenCalledTimes(2); // BoardResource + PinResource/create + }); + + it('throws CommandExecutionError when the board id cannot be resolved', async () => { + await expect(cmd.func(createPageMock([boardMeta(null)]), OK)).rejects.toThrow(CommandExecutionError); + }); + + it('throws CommandExecutionError when create returns no pin id', async () => { + const page = createPageMock([boardMeta('999'), created(null)]); + await expect(cmd.func(page, OK)).rejects.toThrow(CommandExecutionError); + }); + + it('moves the new pin into the section with a follow-up update', async () => { + const page = createPageMock([boardMeta('999'), sections, created('42'), updated]); + await cmd.func(page, { ...OK, section: 'cats' }); + + // PinResource/create must not carry a section: it answers 200 and files the pin at the root. + const create = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[2][0].match(/data=([^"&]+)/)[1])); + expect(create.options.board_id).toBe('999'); + expect(create.options.board_section_id).toBeUndefined(); + + const move = page.evaluate.mock.calls[3][0]; + expect(move).toContain('/resource/PinResource/update/'); + const sent = JSON.parse(decodeURIComponent(move.match(/data=([^"&]+)/)[1])); + expect(sent.options).toEqual({ id: '42', board_id: '999', board_section_id: 'sec-1' }); + }); + + it('throws ArgumentError before creating when --section matches nothing', async () => { + const page = createPageMock([boardMeta('999'), sections]); + await expect(cmd.func(page, { ...OK, section: 'birds' })).rejects.toThrow(/No section matching "birds"/); + expect(page.evaluate).toHaveBeenCalledTimes(2); // nothing was created + }); +}); diff --git a/clis/pinterest/pin-delete.js b/clis/pinterest/pin-delete.js new file mode 100644 index 000000000..40d670981 --- /dev/null +++ b/clis/pinterest/pin-delete.js @@ -0,0 +1,51 @@ +// Pinterest pin-delete — delete one of your own pins (PinResource/delete). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, parsePinId, pinterestResourceDelete, pinterestResourceFetch } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'pin-delete', + access: 'write', + description: 'Delete one of your own pins', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'pin', type: 'string', positional: true, required: true, help: 'Pin id or pin URL, e.g. 1234567890123456' }, + { name: 'confirm', type: 'bool', default: false, help: 'Actually delete — without it the pin is only previewed' }, + ], + columns: ['pinId', 'title', 'board', 'deleted'], + func: async (page, kwargs) => { + const id = parsePinId(kwargs.pin); + const sourceUrl = `/pin/${id}/`; + + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const { data: pin } = await pinterestResourceFetch( + page, + 'PinResource', + { id, field_set_key: 'detailed' }, + sourceUrl, + ); + if (!pin || !pin.id) { + throw new CommandExecutionError(`Could not resolve pin "${id}" (does it exist and do you own it?)`); + } + + const row = { + pinId: String(pin.id), + title: (pin.title || pin.grid_title || '').trim(), + board: (pin.board && pin.board.name) || '', + deleted: false, + }; + + if (kwargs.confirm !== true) { + throw new ArgumentError( + `Refusing to delete pin ${row.pinId}${row.title ? ` "${row.title}"` : ''}${row.board ? ` from board "${row.board}"` : ''} without --confirm`, + 'Re-run with --confirm once you are sure; deleting a pin cannot be undone', + ); + } + + await pinterestResourceDelete(page, 'PinResource', { id: row.pinId }, sourceUrl); + return [{ ...row, deleted: true }]; + }, +}); diff --git a/clis/pinterest/pin-delete.test.js b/clis/pinterest/pin-delete.test.js new file mode 100644 index 000000000..19e6bf6cf --- /dev/null +++ b/clis/pinterest/pin-delete.test.js @@ -0,0 +1,52 @@ +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 './pin-delete.js'; + +let cmd; + +function pinDetail(id) { + return { resource_response: { data: id ? { id, title: 'My Pin', board: { name: 'My Board' } } : {} } }; +} + +const deleted = { resource_response: { data: null } }; +const OK = { pin: '1234567890123456', confirm: true }; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/pin-delete'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest pin-delete', () => { + it('throws ArgumentError on a non-pin reference', async () => { + await expect(cmd.func(createPageMock([]), { ...OK, pin: 'nope' })).rejects.toThrow(ArgumentError); + }); + + it('refuses to delete without --confirm', async () => { + const page = createPageMock([pinDetail('1234567890123456')]); + await expect(cmd.func(page, { pin: '1234567890123456' })).rejects.toThrow(/--confirm/); + expect(page.evaluate).toHaveBeenCalledTimes(1); // looked the pin up, never deleted + }); + + it('throws CommandExecutionError when the pin cannot be resolved', async () => { + await expect(cmd.func(createPageMock([pinDetail(null)]), OK)).rejects.toThrow(CommandExecutionError); + }); + + it('throws AuthRequiredError when the delete is unauthorized', async () => { + const page = createPageMock([pinDetail('1234567890123456'), { __httpError: 401 }]); + await expect(cmd.func(page, OK)).rejects.toThrow(AuthRequiredError); + }); + + it('accepts a pin URL, deletes it, and reports the row', async () => { + const page = createPageMock([pinDetail('1234567890123456'), deleted]); + const result = await cmd.func(page, { pin: 'https://www.pinterest.com/pin/1234567890123456/', confirm: true }); + expect(result).toEqual([{ + pinId: '1234567890123456', + title: 'My Pin', + board: 'My Board', + deleted: true, + }]); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/clis/pinterest/pin-update.js b/clis/pinterest/pin-update.js new file mode 100644 index 000000000..0552cbaad --- /dev/null +++ b/clis/pinterest/pin-update.js @@ -0,0 +1,74 @@ +// Pinterest pin-update — edit a pin's text or move it to another board (PinResource/update). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, resolveBoardTarget, parsePinId, pinterestResourceUpdate, resolveBoardId, resolveSection } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'pin-update', + access: 'write', + description: 'Update a pin\'s title, description, link, or board', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'pin', type: 'string', positional: true, required: true, help: 'Pin id or pin URL, e.g. 1234567890123456' }, + { name: 'title', type: 'string', default: '', help: 'New pin title' }, + // No default: an omitted flag stays undefined, so `--description ""` can clear the field. + { name: 'description', type: 'string', help: 'New pin description (pass "" to clear)' }, + // Pinterest refuses link edits on scraped pins ("你沒有變更此連結的權限"), so this only + // works on pins whose link you own. + { name: 'link', type: 'string', help: 'New destination link (pass "" to clear)' }, + { name: 'board', type: 'string', default: '', help: 'Move the pin to this board: /, board URL, or board id' }, + { name: 'section', type: 'string', default: '', help: 'Move the pin to this board section id or slug (requires --board)' }, + ], + columns: ['pinId', 'title', 'board', 'url'], + func: async (page, kwargs) => { + const id = parsePinId(kwargs.pin); + const title = String(kwargs.title ?? '').trim(); + const description = kwargs.description === undefined ? undefined : String(kwargs.description).trim(); + const link = kwargs.link === undefined ? undefined : String(kwargs.link).trim(); + const boardRef = String(kwargs.board ?? '').trim(); + const section = String(kwargs.section ?? '').trim(); + + if (!title && description === undefined && link === undefined && !boardRef) { + throw new ArgumentError( + 'nothing to update', + 'Pass at least one of --title, --description, --link, or --board', + ); + } + if (section && !boardRef) { + throw new ArgumentError('--section requires --board', 'Sections belong to a board, so pass --board too'); + } + + const options = { id }; + let sourceUrl = `/pin/${id}/`; + + if (boardRef) { + const { username, slug, path, board: preloadedBoard } = await resolveBoardTarget(page, boardRef); + await page.goto(`${PINTEREST_BASE}${path}`); + const { boardId } = await resolveBoardId(page, username, slug, path, preloadedBoard); + options.board_id = boardId; + // PinResource/update is the only endpoint that honours a section, and only under this key. + if (section) options.board_section_id = (await resolveSection(page, boardId, section, path)).sectionId; + sourceUrl = path; + } else { + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + } + + if (title) options.title = title; + if (description !== undefined) options.description = description; + if (link !== undefined) options.link = link; + + const updated = await pinterestResourceUpdate(page, 'PinResource', options, sourceUrl); + if (!updated || !updated.id) { + throw new CommandExecutionError('Pin update did not return the updated pin'); + } + + return [{ + pinId: String(updated.id), + title: (updated.title || updated.grid_title || title || '').trim(), + board: (updated.board && updated.board.name) || boardRef, + url: `${PINTEREST_BASE}/pin/${updated.id}/`, + }]; + }, +}); diff --git a/clis/pinterest/pin-update.test.js b/clis/pinterest/pin-update.test.js new file mode 100644 index 000000000..805dee415 --- /dev/null +++ b/clis/pinterest/pin-update.test.js @@ -0,0 +1,93 @@ +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 './pin-update.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id, name: 'Target Board' } : {} } }; +} + +function updated(overrides = {}) { + return { resource_response: { data: { id: '1234567890123456', title: 'New Title', board: { name: 'Target Board' }, ...overrides } } }; +} + +const sections = { + resource_response: { + data: [ + { id: 'sec-1', title: 'Cats', slug: 'cats', pin_count: 2 }, + { id: 'sec-2', title: 'Dogs', slug: 'dogs', pin_count: 0 }, + ], + }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/pin-update'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest pin-update', () => { + it('throws ArgumentError on a non-pin reference', async () => { + await expect(cmd.func(createPageMock([]), { pin: 'nope', title: 'x' })).rejects.toThrow(ArgumentError); + }); + + it('throws ArgumentError when no field is given', async () => { + await expect(cmd.func(createPageMock([]), { pin: '1234567890123456' })).rejects.toThrow(/nothing to update/); + }); + + it('throws ArgumentError when --section is used without --board', async () => { + await expect(cmd.func(createPageMock([]), { pin: '1234567890123456', title: 'x', section: 'sec-1' })) + .rejects.toThrow(/--section requires --board/); + }); + + it('updates text without touching the board (single call)', async () => { + const page = createPageMock([updated()]); + const result = await cmd.func(page, { pin: '1234567890123456', title: 'New Title' }); + expect(result).toEqual([{ + pinId: '1234567890123456', + title: 'New Title', + board: 'Target Board', + url: 'https://www.pinterest.com/pin/1234567890123456/', + }]); + expect(page.evaluate).toHaveBeenCalledTimes(1); // no board lookup needed + }); + + it('resolves the board and the section when moving the pin', async () => { + const page = createPageMock([boardMeta('999'), sections, updated()]); + await cmd.func(page, { pin: '1234567890123456', board: 'janedoe/target-board', section: 'dogs' }); + const sent = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[2][0].match(/data=([^"&]+)/)[1])); + expect(sent.options).toEqual({ id: '1234567890123456', board_id: '999', board_section_id: 'sec-2' }); + }); + + it('throws ArgumentError when --section matches no section on the board', async () => { + const page = createPageMock([boardMeta('999'), sections]); + await expect(cmd.func(page, { pin: '1234567890123456', board: 'janedoe/target-board', section: 'birds' })) + .rejects.toThrow(/No section matching "birds"/); + }); + + it('throws AuthRequiredError when the update is unauthorized', async () => { + const page = createPageMock([{ __httpError: 401 }]); + await expect(cmd.func(page, { pin: '1234567890123456', title: 'x' })).rejects.toThrow(AuthRequiredError); + }); + + it('throws CommandExecutionError when the update returns no pin', async () => { + const page = createPageMock([{ resource_response: { data: {} } }]); + await expect(cmd.func(page, { pin: '1234567890123456', title: 'x' })).rejects.toThrow(CommandExecutionError); + }); + + it('clears the description when --description "" is passed', async () => { + const page = createPageMock([updated()]); + await cmd.func(page, { pin: '1234567890123456', description: '' }); + const sent = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[0][0].match(/data=([^"&]+)/)[1])); + expect(sent.options.description).toBe(''); + }); + + it('leaves description untouched when the flag is omitted', async () => { + const page = createPageMock([updated()]); + await cmd.func(page, { pin: '1234567890123456', title: 'New Title' }); + const sent = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[0][0].match(/data=([^"&]+)/)[1])); + expect('description' in sent.options).toBe(false); + }); +}); diff --git a/clis/pinterest/pin.js b/clis/pinterest/pin.js new file mode 100644 index 000000000..7e5624dde --- /dev/null +++ b/clis/pinterest/pin.js @@ -0,0 +1,48 @@ +// Pinterest pin — details of a single pin (PinResource, one row). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, parsePinId, pickPinImage, pinterestResourceFetch } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'pin', + access: 'read', + description: 'Get details of a Pinterest pin', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'pin', type: 'string', positional: true, required: true, help: 'Pin id or pin URL, e.g. 1234567890123456' }, + ], + columns: ['pinId', 'title', 'description', 'pinner', 'board', 'saveCount', 'commentCount', 'link', 'imageUrl', 'url'], + func: async (page, kwargs) => { + const id = parsePinId(kwargs.pin); + const sourceUrl = `/pin/${id}/`; + + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const { data: pin } = await pinterestResourceFetch( + page, + 'PinResource', + { id, field_set_key: 'detailed' }, + sourceUrl, + ); + if (!pin || !pin.id) { + throw new EmptyResultError('pinterest pin', `pin "${id}" not found`); + } + + return [ + { + pinId: String(pin.id), + title: (pin.title || pin.grid_title || '').trim(), + description: (typeof pin.description === 'string' ? pin.description : '').trim(), + pinner: (pin.pinner && pin.pinner.username) || '', + board: (pin.board && pin.board.name) || '', + saveCount: typeof pin.repin_count === 'number' ? pin.repin_count : 0, + commentCount: typeof pin.comment_count === 'number' ? pin.comment_count : 0, + link: pin.link || '', + imageUrl: pickPinImage(pin.images), + url: `${PINTEREST_BASE}/pin/${pin.id}/`, + }, + ]; + }, +}); diff --git a/clis/pinterest/pin.test.js b/clis/pinterest/pin.test.js new file mode 100644 index 000000000..79a058318 --- /dev/null +++ b/clis/pinterest/pin.test.js @@ -0,0 +1,76 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './pin.js'; + +let cmd; + +/** PinResource response: the raw pin object under resource_response.data. */ +function pinDetail(overrides = {}) { + return { + resource_response: { + data: { + id: '1234567890123456', + title: 'Scandinavian Living Room', + description: 'Soft neutral tones.', + pinner: { username: 'janedoe' }, + board: { name: 'House' }, + repin_count: 614, + comment_count: 3, + link: 'https://example.com/article', + images: { orig: { url: 'https://i.pinimg.com/originals/a/b/c.png' } }, + ...overrides, + }, + }, + }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/pin'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest pin', () => { + it('throws ArgumentError on a non-pin reference', async () => { + await expect(cmd.func(createPageMock([]), { pin: 'not-a-pin' })).rejects.toThrow(ArgumentError); + }); + + it('accepts a bare id and a pin URL', async () => { + for (const pin of ['1234567890123456', 'https://www.pinterest.com/pin/1234567890123456/']) { + const page = createPageMock([pinDetail()]); + const result = await cmd.func(page, { pin }); + expect(result).toHaveLength(1); + } + }); + + it('throws AuthRequiredError on 401', async () => { + await expect(cmd.func(createPageMock([{ __httpError: 401 }]), { pin: '1234567890123456' })).rejects.toThrow(AuthRequiredError); + }); + + it('maps the full detail row', async () => { + const result = await cmd.func(createPageMock([pinDetail()]), { pin: '1234567890123456' }); + expect(result[0]).toEqual({ + pinId: '1234567890123456', + title: 'Scandinavian Living Room', + description: 'Soft neutral tones.', + pinner: 'janedoe', + board: 'House', + saveCount: 614, + commentCount: 3, + link: 'https://example.com/article', + imageUrl: 'https://i.pinimg.com/originals/a/b/c.png', + url: 'https://www.pinterest.com/pin/1234567890123456/', + }); + }); + + it('defaults missing counts and link', async () => { + const result = await cmd.func(createPageMock([pinDetail({ repin_count: undefined, comment_count: undefined, link: null })]), { pin: '1234567890123456' }); + expect(result[0]).toMatchObject({ saveCount: 0, commentCount: 0, link: '' }); + }); + + it('throws EmptyResultError when the pin is missing', async () => { + const page = createPageMock([{ resource_response: { data: {} } }]); + await expect(cmd.func(page, { pin: '999' })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/save.js b/clis/pinterest/save.js new file mode 100644 index 000000000..3c07a397b --- /dev/null +++ b/clis/pinterest/save.js @@ -0,0 +1,62 @@ +// Pinterest save — repin an existing pin to your profile or a board (RepinResource/create; no board_id → profile). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, movePinToSection, parsePinId, pinterestResourceCreate, resolveBoardId, resolveBoardTarget, resolveSection } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'save', + access: 'write', + description: 'Save a pin to your profile or a board', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'pin', type: 'string', positional: true, required: true, help: 'Pin id or pin URL to repin, e.g. 1234567890123456' }, + { name: 'board', type: 'string', help: 'Target board: /, board URL, or board id (omit to save to your profile)' }, + { name: 'section', type: 'string', default: '', help: 'Optional board section id or slug (requires --board)' }, + ], + columns: ['pinId', 'sourcePinId', 'board', 'url'], + func: async (page, kwargs) => { + const sourcePinId = parsePinId(kwargs.pin); + const boardRef = String(kwargs.board ?? '').trim(); + const section = String(kwargs.section ?? '').trim(); + if (section && !boardRef) { + throw new ArgumentError('--section requires --board', 'Sections belong to a board, so pass --board too'); + } + + const options = { pin_id: sourcePinId }; + let sourceUrl = '/'; + let sectionId = ''; + + if (boardRef) { + const { username, slug, path, board } = await resolveBoardTarget(page, boardRef); + await page.goto(`${PINTEREST_BASE}${path}`); + + const { boardId } = await resolveBoardId(page, username, slug, path, board); + options.board_id = boardId; + sourceUrl = path; + // Validate --section before repinning, so a bad value fails before anything is created. + if (section) sectionId = (await resolveSection(page, options.board_id, section, path)).sectionId; + } else { + // No board given: Pinterest routes a boardless repin to the profile's Quick Saves board. + await page.goto(`${PINTEREST_BASE}/`); + } + + const created = await pinterestResourceCreate(page, 'RepinResource', options, sourceUrl); + const newId = created && created.id; + if (!newId) { + throw new CommandExecutionError('Repin did not return a pin id'); + } + + if (sectionId) { + await movePinToSection(page, newId, options.board_id, sectionId, sourceUrl); + } + + return [{ + pinId: String(newId), + sourcePinId, + board: (created.board && created.board.name) || (boardRef || 'profile'), + url: `${PINTEREST_BASE}/pin/${newId}/`, + }]; + }, +}); diff --git a/clis/pinterest/save.test.js b/clis/pinterest/save.test.js new file mode 100644 index 000000000..d048f84f0 --- /dev/null +++ b/clis/pinterest/save.test.js @@ -0,0 +1,136 @@ +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 './save.js'; + +let cmd; + +function boardMeta(id) { + return { resource_response: { data: id ? { id, name: 'Home' } : {} } }; +} + +function repinned(id) { + return { resource_response: { data: id ? { id, board: { name: 'Home' } } : {} } }; +} + +const sections = { + resource_response: { + data: [ + { id: 'sec-1', title: 'Cats', slug: 'cats', pin_count: 2 }, + { id: 'sec-2', title: 'Dogs', slug: 'dogs', pin_count: 0 }, + ], + }, +}; + +const updated = { resource_response: { data: { id: '42' } } }; + +const OK = { pin: '1234567890123456', board: 'me/home' }; + +const boardById = { + resource_response: { data: { id: '999', name: 'My Board', url: '/janedoe/my-board/' } }, +}; + +beforeAll(() => { + cmd = getRegistry().get('pinterest/save'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest save', () => { + it('throws ArgumentError on a non-pin reference', async () => { + await expect(cmd.func(createPageMock([]), { ...OK, pin: 'nope' })).rejects.toThrow(ArgumentError); + }); + + it('throws ArgumentError on a board reference that is neither / 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('throws CommandExecutionError when the board id cannot be resolved', async () => { + await expect(cmd.func(createPageMock([boardMeta(null)]), OK)).rejects.toThrow(CommandExecutionError); + }); + + it('throws AuthRequiredError when the repin is unauthorized', async () => { + const page = createPageMock([boardMeta('999'), { __httpError: 401 }]); + await expect(cmd.func(page, OK)).rejects.toThrow(AuthRequiredError); + }); + + it('resolves the board then repins and returns the row', async () => { + const page = createPageMock([boardMeta('999'), repinned('42')]); + const result = await cmd.func(page, OK); + expect(result).toEqual([{ + pinId: '42', + sourcePinId: '1234567890123456', + board: 'Home', + url: 'https://www.pinterest.com/pin/42/', + }]); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('moves the repin into the section with a follow-up update', async () => { + const page = createPageMock([boardMeta('999'), sections, repinned('42'), updated]); + await cmd.func(page, { ...OK, section: 'cats' }); + + // RepinResource/create must not carry a section: it answers 200 and ignores it. + const repin = JSON.parse(decodeURIComponent(page.evaluate.mock.calls[2][0].match(/data=([^"&]+)/)[1])); + expect(repin.options.board_id).toBe('999'); + expect(repin.options.board_section_id).toBeUndefined(); + + const move = page.evaluate.mock.calls[3][0]; + expect(move).toContain('/resource/PinResource/update/'); + const sent = JSON.parse(decodeURIComponent(move.match(/data=([^"&]+)/)[1])); + expect(sent.options).toEqual({ id: '42', board_id: '999', board_section_id: 'sec-1' }); + }); + + it('throws ArgumentError before repinning when --section matches nothing', async () => { + const page = createPageMock([boardMeta('999'), sections]); + await expect(cmd.func(page, { ...OK, section: 'birds' })).rejects.toThrow(/No section matching "birds"/); + expect(page.evaluate).toHaveBeenCalledTimes(2); // nothing was created + }); + + it('reports the created pin id when the section move fails', async () => { + const page = createPageMock([boardMeta('999'), sections, repinned('42'), { __httpError: 500 }]); + await expect(cmd.func(page, { ...OK, section: 'sec-1' })).rejects.toThrow(/42 was created but could not be moved/); + }); + + it('throws CommandExecutionError when the repin returns no id', async () => { + const page = createPageMock([boardMeta('999'), repinned(null)]); + await expect(cmd.func(page, OK)).rejects.toThrow(CommandExecutionError); + }); + + it('throws ArgumentError for --section without --board instead of dropping it', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { pin: '1234567890123456', section: 'cats' })) + .rejects.toThrow(/--section requires --board/); + expect(page.evaluate).not.toHaveBeenCalled(); // nothing was repinned + }); + + it('saves to profile when no board is given (skips board lookup)', async () => { + const page = createPageMock([{ resource_response: { data: { id: '43', board: { name: 'Profile' } } } }]); + const result = await cmd.func(page, { pin: '1234567890123456' }); + expect(result).toEqual([{ + pinId: '43', + sourcePinId: '1234567890123456', + board: 'Profile', + url: 'https://www.pinterest.com/pin/43/', + }]); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('labels the profile save "profile" when the repin omits a board', async () => { + const page = createPageMock([{ resource_response: { data: { id: '44' } } }]); + const result = await cmd.func(page, { pin: '1234567890123456' }); + expect(result[0].board).toBe('profile'); + }); +}); diff --git a/clis/pinterest/search-boards.js b/clis/pinterest/search-boards.js new file mode 100644 index 000000000..155c919b8 --- /dev/null +++ b/clis/pinterest/search-boards.js @@ -0,0 +1,54 @@ +// Pinterest search-boards — search for boards (BaseSearchResource scope=boards). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { DEFAULT_PAGE_SIZE, PINTEREST_BASE, collectResults, requireLimit } from './utils.js'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +cli({ + site: 'pinterest', + name: 'search-boards', + access: 'read', + description: 'Search for boards on Pinterest', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', type: 'string', positional: true, required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of boards (max ${MAX_LIMIT})` }, + ], + columns: ['boardId', 'name', 'pinCount', 'owner', 'description', 'url'], + func: async (page, kwargs) => { + const query = String(kwargs.query ?? '').trim(); + if (!query) throw new ArgumentError('query is required'); + const limit = requireLimit(kwargs.limit, { fallback: DEFAULT_LIMIT, max: MAX_LIMIT }); + + const sourceUrl = `/search/boards/?q=${encodeURIComponent(query)}`; + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const rows = await collectResults(page, { + resource: 'BaseSearchResource', + baseOptions: { query, scope: 'boards' }, + sourceUrl, + limit, + keyField: 'boardId', + pageSize: DEFAULT_PAGE_SIZE, + mapItem: (board) => { + if (!board || board.type !== 'board' || !board.id) return null; + return { + boardId: String(board.id), + name: board.name || '', + pinCount: typeof board.pin_count === 'number' ? board.pin_count : 0, + owner: (board.owner && board.owner.username) || '', + description: (board.description || '').trim(), + url: board.url ? `${PINTEREST_BASE}${board.url}` : '', + }; + }, + }); + + if (rows.length === 0) { + throw new EmptyResultError('pinterest search-boards', `no boards found for "${query}"`); + } + return rows; + }, +}); diff --git a/clis/pinterest/search-boards.test.js b/clis/pinterest/search-boards.test.js new file mode 100644 index 000000000..debb16b2f --- /dev/null +++ b/clis/pinterest/search-boards.test.js @@ -0,0 +1,44 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './search-boards.js'; + +let cmd; + +function feed(results, bookmark = null) { + return { resource_response: { data: { results }, bookmark } }; +} + +function board(overrides = {}) { + return { type: 'board', id: '999', name: 'Coffee', pin_count: 120, owner: { username: 'barista' }, description: 'beans', url: '/barista/coffee/', ...overrides }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/search-boards'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest search-boards', () => { + it('throws ArgumentError on blank query', async () => { + await expect(cmd.func(createPageMock([]), { query: ' ' })).rejects.toThrow(ArgumentError); + }); + + it('maps board fields and skips non-board items', async () => { + const page = createPageMock([feed([board({ id: '1' }), { type: 'pin', id: 'p' }, board({ id: '2' })])]); + const result = await cmd.func(page, { query: 'coffee', limit: 10 }); + expect(result.map((r) => r.boardId)).toEqual(['1', '2']); + expect(result[0]).toEqual({ + boardId: '1', + name: 'Coffee', + pinCount: 120, + owner: 'barista', + description: 'beans', + url: 'https://www.pinterest.com/barista/coffee/', + }); + }); + + it('throws EmptyResultError when nothing matches', async () => { + await expect(cmd.func(createPageMock([feed([], null)]), { query: 'zzz' })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/search-pins.js b/clis/pinterest/search-pins.js new file mode 100644 index 000000000..e399380b6 --- /dev/null +++ b/clis/pinterest/search-pins.js @@ -0,0 +1,43 @@ +// Pinterest search-pins — search pins by keyword (BaseSearchResource scope=pins). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { DEFAULT_PAGE_SIZE, PINTEREST_BASE, collectPins, requireLimit } from './utils.js'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +cli({ + site: 'pinterest', + name: 'search-pins', + access: 'read', + description: 'Search pins on Pinterest', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', type: 'string', positional: true, required: true, help: 'Search keyword' }, + { 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 query = String(kwargs.query ?? '').trim(); + if (!query) throw new ArgumentError('query is required'); + + const limit = requireLimit(kwargs.limit, { fallback: DEFAULT_LIMIT, max: MAX_LIMIT }); + const sourceUrl = `/search/pins/?q=${encodeURIComponent(query)}`; + + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const rows = await collectPins(page, { + resource: 'BaseSearchResource', + baseOptions: { query, scope: 'pins', appliedProductFilters: '---', auto_correction_disabled: false }, + sourceUrl, + limit, + pageSize: DEFAULT_PAGE_SIZE, + }); + + if (rows.length === 0) { + throw new EmptyResultError('pinterest search-pins', `no pins found for "${query}"`); + } + return rows; + }, +}); diff --git a/clis/pinterest/search-pins.test.js b/clis/pinterest/search-pins.test.js new file mode 100644 index 000000000..51c66c77a --- /dev/null +++ b/clis/pinterest/search-pins.test.js @@ -0,0 +1,111 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './search-pins.js'; + +let cmd; + +/** Wrap raw pin results in the resource_response envelope the endpoint returns. */ +function searchResponse(results, bookmark = null) { + return { resource_response: { data: { results }, bookmark } }; +} + +function pin(overrides = {}) { + return { + type: 'pin', + id: '111', + title: 'Cozy Nordic Living Room', + description: 'Soft neutral tones and light wood.', + pinner: { username: 'janedoe' }, + board: { name: 'Home' }, + images: { orig: { url: 'https://i.pinimg.com/originals/aa/bb/cc.jpg' } }, + ...overrides, + }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/search-pins'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest search-pins', () => { + it('throws ArgumentError on blank query', async () => { + const page = createPageMock([]); + await expect(cmd.func(page, { query: ' ', limit: 5 })).rejects.toThrow(ArgumentError); + }); + + it('throws AuthRequiredError on 401', async () => { + const page = createPageMock([{ __httpError: 401 }]); + await expect(cmd.func(page, { query: 'nordic interior', limit: 5 })).rejects.toThrow(AuthRequiredError); + }); + + it('throws CommandExecutionError on 403 (missing CSRF), not an auth prompt', async () => { + const page = createPageMock([{ __httpError: 403 }]); + await expect(cmd.func(page, { query: 'nordic interior', limit: 5 })).rejects.toThrow(CommandExecutionError); + }); + + it('maps pin fields into columns', async () => { + const page = createPageMock([searchResponse([pin()])]); + const result = await cmd.func(page, { query: 'nordic interior', limit: 10 }); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + pinId: '111', + title: 'Cozy Nordic Living Room', + description: 'Soft neutral tones and light wood.', + pinner: 'janedoe', + board: 'Home', + imageUrl: 'https://i.pinimg.com/originals/aa/bb/cc.jpg', + url: 'https://www.pinterest.com/pin/111/', + }); + }); + + it('falls back to grid_title and best-available image; tolerates missing fields', async () => { + const page = createPageMock([searchResponse([ + pin({ id: '222', title: '', grid_title: 'Grid Title', description: ' ', pinner: undefined, board: undefined, images: { '736x': { url: 'https://i.pinimg.com/736x/x.jpg' } } }), + ])]); + const result = await cmd.func(page, { query: 'x', limit: 10 }); + expect(result[0]).toMatchObject({ + pinId: '222', + title: 'Grid Title', + description: '', + pinner: '', + board: '', + imageUrl: 'https://i.pinimg.com/736x/x.jpg', + }); + }); + + it('drops promoted (ad) pins and non-pin items', async () => { + const page = createPageMock([searchResponse([ + pin({ id: '1' }), + pin({ id: 'ad1', is_promoted: true }), + { type: 'story', id: 's1' }, + pin({ id: '2' }), + ])]); + const result = await cmd.func(page, { query: 'x', limit: 10 }); + expect(result.map((r) => r.pinId)).toEqual(['1', '2']); + }); + + it('respects the limit and stops paginating once satisfied', async () => { + const page = createPageMock([searchResponse([pin({ id: '1' }), pin({ id: '2' }), pin({ id: '3' })], 'bm1')]); + const result = await cmd.func(page, { query: 'x', limit: 2 }); + expect(result).toHaveLength(2); + // Only one page fetched: the second queued response is never consumed. + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('paginates across pages via the bookmark, de-duplicating pins', async () => { + const page = createPageMock([ + searchResponse([pin({ id: '1' }), pin({ id: '2' })], 'bm1'), + searchResponse([pin({ id: '2' }), pin({ id: '3' })], null), + ]); + const result = await cmd.func(page, { query: 'x', limit: 5 }); + expect(result.map((r) => r.pinId)).toEqual(['1', '2', '3']); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('throws EmptyResultError when no pins are found', async () => { + const page = createPageMock([searchResponse([], null)]); + await expect(cmd.func(page, { query: 'zzznope', limit: 10 })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/search-users.js b/clis/pinterest/search-users.js new file mode 100644 index 000000000..e78cf1815 --- /dev/null +++ b/clis/pinterest/search-users.js @@ -0,0 +1,53 @@ +// Pinterest search-users — search for users (BaseSearchResource scope=users). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { DEFAULT_PAGE_SIZE, PINTEREST_BASE, collectResults, requireLimit } from './utils.js'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +cli({ + site: 'pinterest', + name: 'search-users', + access: 'read', + description: 'Search for users on Pinterest', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'query', type: 'string', positional: true, required: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of users (max ${MAX_LIMIT})` }, + ], + columns: ['username', 'fullName', 'followerCount', 'pinCount', 'url'], + func: async (page, kwargs) => { + const query = String(kwargs.query ?? '').trim(); + if (!query) throw new ArgumentError('query is required'); + const limit = requireLimit(kwargs.limit, { fallback: DEFAULT_LIMIT, max: MAX_LIMIT }); + + const sourceUrl = `/search/users/?q=${encodeURIComponent(query)}`; + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const rows = await collectResults(page, { + resource: 'BaseSearchResource', + baseOptions: { query, scope: 'users' }, + sourceUrl, + limit, + keyField: 'username', + pageSize: DEFAULT_PAGE_SIZE, + mapItem: (user) => { + if (!user || user.type !== 'user' || !user.username) return null; + return { + username: user.username, + fullName: (user.full_name || '').trim(), + followerCount: typeof user.follower_count === 'number' ? user.follower_count : 0, + pinCount: typeof user.pin_count === 'number' ? user.pin_count : 0, + url: `${PINTEREST_BASE}/${user.username}/`, + }; + }, + }); + + if (rows.length === 0) { + throw new EmptyResultError('pinterest search-users', `no users found for "${query}"`); + } + return rows; + }, +}); diff --git a/clis/pinterest/search-users.test.js b/clis/pinterest/search-users.test.js new file mode 100644 index 000000000..8371ce508 --- /dev/null +++ b/clis/pinterest/search-users.test.js @@ -0,0 +1,43 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './search-users.js'; + +let cmd; + +function feed(results, bookmark = null) { + return { resource_response: { data: { results }, bookmark } }; +} + +function user(overrides = {}) { + return { type: 'user', id: 'u1', username: 'mrcoffee', full_name: 'Mr Coffee', follower_count: 22441, pin_count: 500, ...overrides }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/search-users'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest search-users', () => { + it('throws ArgumentError on blank query', async () => { + await expect(cmd.func(createPageMock([]), { query: ' ' })).rejects.toThrow(ArgumentError); + }); + + it('maps user fields and skips non-user items', async () => { + const page = createPageMock([feed([user({ username: 'a' }), { type: 'pin', id: 'p' }, user({ username: 'b' })])]); + const result = await cmd.func(page, { query: 'coffee', limit: 10 }); + expect(result.map((r) => r.username)).toEqual(['a', 'b']); + expect(result[0]).toEqual({ + username: 'a', + fullName: 'Mr Coffee', + followerCount: 22441, + pinCount: 500, + url: 'https://www.pinterest.com/a/', + }); + }); + + it('throws EmptyResultError when nothing matches', async () => { + await expect(cmd.func(createPageMock([feed([], null)]), { query: 'zzz' })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/user-boards.js b/clis/pinterest/user-boards.js new file mode 100644 index 000000000..ef0332049 --- /dev/null +++ b/clis/pinterest/user-boards.js @@ -0,0 +1,59 @@ +// Pinterest user-boards — list the boards owned by a user (BoardsResource). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { DEFAULT_PAGE_SIZE, PINTEREST_BASE, collectResults, parseUsername, requireLimit } from './utils.js'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 250; +const SORTS = ['last_pinned_to', 'alphabetical', 'custom']; + +cli({ + site: 'pinterest', + name: 'user-boards', + access: 'read', + description: 'List a user\'s boards', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'username', type: 'string', positional: true, required: true, help: 'Username or profile URL, e.g. janedoe' }, + { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of boards (max ${MAX_LIMIT})` }, + { name: 'sort', type: 'string', default: 'last_pinned_to', choices: SORTS, help: 'Board sort order' }, + ], + columns: ['boardId', 'name', 'pinCount', 'sectionCount', 'privacy', 'url'], + func: async (page, kwargs) => { + const username = parseUsername(kwargs.username); + const limit = requireLimit(kwargs.limit, { fallback: DEFAULT_LIMIT, max: MAX_LIMIT }); + const sort = String(kwargs.sort ?? 'last_pinned_to'); + if (!SORTS.includes(sort)) { + throw new ArgumentError(`Unknown sort "${sort}". Valid: ${SORTS.join(', ')}`); + } + + const sourceUrl = `/${username}/`; + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const rows = await collectResults(page, { + resource: 'BoardsResource', + baseOptions: { username, sort, privacy_filter: 'all', field_set_key: 'profile_grid_item' }, + sourceUrl, + limit, + keyField: 'boardId', + pageSize: DEFAULT_PAGE_SIZE, + mapItem: (board) => { + if (!board || !board.id) return null; + return { + boardId: String(board.id), + name: board.name || '', + pinCount: typeof board.pin_count === 'number' ? board.pin_count : 0, + sectionCount: typeof board.section_count === 'number' ? board.section_count : 0, + privacy: board.privacy || 'public', + url: board.url ? `${PINTEREST_BASE}${board.url}` : '', + }; + }, + }); + + if (rows.length === 0) { + throw new EmptyResultError('pinterest user-boards', `no boards found for user "${username}"`); + } + return rows; + }, +}); diff --git a/clis/pinterest/user-boards.test.js b/clis/pinterest/user-boards.test.js new file mode 100644 index 000000000..76ed92099 --- /dev/null +++ b/clis/pinterest/user-boards.test.js @@ -0,0 +1,60 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './user-boards.js'; + +let cmd; + +function feed(results, bookmark = null) { + return { resource_response: { data: results, bookmark } }; +} + +function board(overrides = {}) { + return { id: '999', name: 'My Board', pin_count: 245, section_count: 4, privacy: 'public', url: '/janedoe/my-board/', ...overrides }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/user-boards'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest user-boards', () => { + it('throws ArgumentError on blank username', async () => { + await expect(cmd.func(createPageMock([]), { username: ' ' })).rejects.toThrow(ArgumentError); + }); + + it('throws ArgumentError on an unknown sort', async () => { + await expect(cmd.func(createPageMock([]), { username: 'janedoe', sort: 'nope' })).rejects.toThrow(ArgumentError); + }); + + it('throws AuthRequiredError on 401', async () => { + await expect(cmd.func(createPageMock([{ __httpError: 401 }]), { username: 'janedoe' })).rejects.toThrow(AuthRequiredError); + }); + + it('maps board fields', async () => { + const page = createPageMock([feed([board()])]); + const result = await cmd.func(page, { username: 'janedoe', limit: 10 }); + expect(result[0]).toEqual({ + boardId: '999', + name: 'My Board', + pinCount: 245, + sectionCount: 4, + privacy: 'public', + url: 'https://www.pinterest.com/janedoe/my-board/', + }); + }); + + it('paginates via bookmark and de-duplicates by boardId', async () => { + const page = createPageMock([ + feed([board({ id: '1' }), board({ id: '2' })], 'bm1'), + feed([board({ id: '2' }), board({ id: '3' })], null), + ]); + const result = await cmd.func(page, { username: 'janedoe', limit: 10 }); + expect(result.map((r) => r.boardId)).toEqual(['1', '2', '3']); + }); + + it('throws EmptyResultError when the user has no boards', async () => { + await expect(cmd.func(createPageMock([feed([], null)]), { username: 'ghost' })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/user-pins.js b/clis/pinterest/user-pins.js new file mode 100644 index 000000000..254644132 --- /dev/null +++ b/clis/pinterest/user-pins.js @@ -0,0 +1,41 @@ +// Pinterest user-pins — pins created by a user (UserPinsResource). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { DEFAULT_PAGE_SIZE, PINTEREST_BASE, collectPins, parseUsername, requireLimit } from './utils.js'; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +cli({ + site: 'pinterest', + name: 'user-pins', + access: 'read', + description: 'List pins created by a Pinterest user', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'username', type: 'string', positional: true, required: true, help: 'Username or profile URL, e.g. janedoe' }, + { 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 username = parseUsername(kwargs.username); + const limit = requireLimit(kwargs.limit, { fallback: DEFAULT_LIMIT, max: MAX_LIMIT }); + + const sourceUrl = `/${username}/`; + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const rows = await collectPins(page, { + resource: 'UserPinsResource', + baseOptions: { username, field_set_key: 'grid_item' }, + sourceUrl, + limit, + pageSize: DEFAULT_PAGE_SIZE, + }); + + if (rows.length === 0) { + throw new EmptyResultError('pinterest user-pins', `no pins found for user "${username}"`); + } + return rows; + }, +}); diff --git a/clis/pinterest/user-pins.test.js b/clis/pinterest/user-pins.test.js new file mode 100644 index 000000000..110d3736d --- /dev/null +++ b/clis/pinterest/user-pins.test.js @@ -0,0 +1,80 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './user-pins.js'; + +let cmd; + +function feed(results, bookmark = null) { + return { resource_response: { data: { results }, bookmark } }; +} + +function pin(overrides = {}) { + return { + type: 'pin', + id: '111', + title: 'Nordic Chair', + description: 'Light wood.', + pinner: { username: 'janedoe' }, + board: { name: 'Furniture' }, + images: { orig: { url: 'https://i.pinimg.com/originals/a/b/c.jpg' } }, + ...overrides, + }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/user-pins'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest user-pins', () => { + it('throws ArgumentError on blank username', async () => { + await expect(cmd.func(createPageMock([]), { username: ' ', limit: 5 })).rejects.toThrow(ArgumentError); + }); + + it('accepts profile URL form', async () => { + const page = createPageMock([feed([pin()])]); + const result = await cmd.func(page, { username: 'https://www.pinterest.com/janedoe/', limit: 5 }); + expect(result).toHaveLength(1); + }); + + it('rejects an @-prefixed username (Pinterest has no @-handles)', async () => { + await expect(cmd.func(createPageMock([feed([pin()])]), { username: '@janedoe', limit: 5 })) + .rejects.toThrow(ArgumentError); + }); + + it('throws AuthRequiredError on 401', async () => { + await expect(cmd.func(createPageMock([{ __httpError: 401 }]), { username: 'janedoe', limit: 5 })).rejects.toThrow(AuthRequiredError); + }); + + it('throws CommandExecutionError on 403', async () => { + await expect(cmd.func(createPageMock([{ __httpError: 403 }]), { username: 'janedoe', limit: 5 })).rejects.toThrow(CommandExecutionError); + }); + + it('maps pin fields and drops ads/non-pins', async () => { + const page = createPageMock([feed([ + pin({ id: '1' }), + pin({ id: 'ad', is_promoted: true }), + { type: 'story', id: 's' }, + pin({ id: '2' }), + ])]); + const result = await cmd.func(page, { username: 'janedoe', limit: 10 }); + expect(result.map((r) => r.pinId)).toEqual(['1', '2']); + expect(result[0]).toMatchObject({ pinner: 'janedoe', board: 'Furniture', url: 'https://www.pinterest.com/pin/1/' }); + }); + + it('paginates via bookmark and de-duplicates', async () => { + const page = createPageMock([ + feed([pin({ id: '1' }), pin({ id: '2' })], 'bm1'), + feed([pin({ id: '2' }), pin({ id: '3' })], null), + ]); + const result = await cmd.func(page, { username: 'janedoe', limit: 5 }); + expect(result.map((r) => r.pinId)).toEqual(['1', '2', '3']); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('throws EmptyResultError when the user has no pins', async () => { + await expect(cmd.func(createPageMock([feed([], null)]), { username: 'ghost', limit: 5 })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/user.js b/clis/pinterest/user.js new file mode 100644 index 000000000..d0b9c3077 --- /dev/null +++ b/clis/pinterest/user.js @@ -0,0 +1,48 @@ +// Pinterest user — a user's public profile stats (UserResource, single row). +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { PINTEREST_BASE, parseUsername, pinterestResourceFetch } from './utils.js'; + +cli({ + site: 'pinterest', + name: 'user', + access: 'read', + description: 'Get a Pinterest user\'s public profile stats', + domain: 'www.pinterest.com', + strategy: Strategy.COOKIE, + args: [ + { name: 'username', type: 'string', positional: true, required: true, help: 'Username or profile URL, e.g. janedoe' }, + ], + columns: ['username', 'fullName', 'followerCount', 'followingCount', 'interestFollowingCount', 'pinCount', 'boardCount', 'about', 'website', 'url'], + func: async (page, kwargs) => { + const username = parseUsername(kwargs.username); + const sourceUrl = `/${username}/`; + + await page.goto(`${PINTEREST_BASE}${sourceUrl}`); + + const { data: user } = await pinterestResourceFetch( + page, + 'UserResource', + { username, field_set_key: 'profile' }, + sourceUrl, + ); + if (!user || !user.username) { + throw new EmptyResultError('pinterest user', `user "${username}" not found`); + } + + const num = (value) => (typeof value === 'number' ? value : 0); + return [{ + username: user.username, + fullName: (user.full_name || '').trim(), + followerCount: num(user.follower_count), + // following_count is Pinterest's API total (followed people + topics/interests). + followingCount: num(user.following_count), + interestFollowingCount: num(user.interest_following_count), + pinCount: num(user.pin_count), + boardCount: num(user.board_count), + about: (user.about || '').trim(), + website: user.domain_url || '', + url: `${PINTEREST_BASE}/${user.username}/`, + }]; + }, +}); diff --git a/clis/pinterest/user.test.js b/clis/pinterest/user.test.js new file mode 100644 index 000000000..f3b27d241 --- /dev/null +++ b/clis/pinterest/user.test.js @@ -0,0 +1,67 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; +import { createPageMock } from '../test-utils.js'; +import './user.js'; + +let cmd; + +function profile(overrides = {}) { + return { + resource_response: { + data: { + username: 'janedoe', + full_name: 'Jane Doe', + follower_count: 83, + following_count: 146, + interest_following_count: 6, + pin_count: 3135, + board_count: 36, + about: 'hi', + domain_url: 'https://example.com', + ...overrides, + }, + }, + }; +} + +beforeAll(() => { + cmd = getRegistry().get('pinterest/user'); + expect(cmd?.func).toBeTypeOf('function'); +}); + +describe('pinterest user', () => { + it('throws ArgumentError on blank username', async () => { + await expect(cmd.func(createPageMock([]), { username: ' ' })).rejects.toThrow(ArgumentError); + }); + + it('throws AuthRequiredError on 401', async () => { + await expect(cmd.func(createPageMock([{ __httpError: 401 }]), { username: 'janedoe' })).rejects.toThrow(AuthRequiredError); + }); + + it('maps the profile row', async () => { + const result = await cmd.func(createPageMock([profile()]), { username: 'janedoe' }); + expect(result[0]).toEqual({ + username: 'janedoe', + fullName: 'Jane Doe', + followerCount: 83, + followingCount: 146, + interestFollowingCount: 6, + pinCount: 3135, + boardCount: 36, + about: 'hi', + website: 'https://example.com', + url: 'https://www.pinterest.com/janedoe/', + }); + }); + + it('defaults missing counts/strings', async () => { + const result = await cmd.func(createPageMock([profile({ follower_count: undefined, about: undefined, domain_url: undefined })]), { username: 'janedoe' }); + expect(result[0]).toMatchObject({ followerCount: 0, about: '', website: '' }); + }); + + it('throws EmptyResultError when the user is missing', async () => { + const page = createPageMock([{ resource_response: { data: {} } }]); + await expect(cmd.func(page, { username: 'ghost' })).rejects.toThrow(EmptyResultError); + }); +}); diff --git a/clis/pinterest/utils.js b/clis/pinterest/utils.js new file mode 100644 index 000000000..35ff304bc --- /dev/null +++ b/clis/pinterest/utils.js @@ -0,0 +1,396 @@ +// Shared helpers for Pinterest adapters. +// Resources are POST /resource/Resource/{get,create}/ with a form-urlencoded body +// and the X-CSRFToken + X-Pinterest-PWS-Handler headers; feeds paginate via `bookmark`. +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +export const PINTEREST_DOMAIN = 'www.pinterest.com'; +export const PINTEREST_BASE = 'https://www.pinterest.com'; +export const DEFAULT_PAGE_SIZE = 25; + +/** Any valid handler value passes; the server only rejects missing/garbage ones. */ +const PWS_HANDLER = 'www/[username]/[slug].js'; + +/** Resource actions that mutate; only these treat a 403 as "you need to log in". */ +const WRITE_ACTIONS = new Set(['create', 'update', 'delete']); + +/** Unwrap the { session, data } envelope some browser-bridge versions add. */ +export function unwrapEvaluateResult(payload) { + const isEnvelope = payload + && typeof payload === 'object' + && !Array.isArray(payload) + && 'session' in payload + && 'data' in payload; + return isEnvelope ? payload.data : payload; +} + +/** Validate a positive-integer limit (throws instead of silently clamping). */ +export function requireLimit(value, { fallback, max, name = 'limit' }) { + const parsed = Number(value ?? fallback); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new ArgumentError(`${name} must be a positive integer`, `e.g. --${name} 10`); + } + if (max && parsed > max) { + throw new ArgumentError(`${name} must be <= ${max}`, `Lower --${name} to ${max} or below`); + } + return parsed; +} + +/** + * Fold a slug for comparison. Pinterest keeps non-ASCII characters in slugs (e.g. + * `naive-café-中文`) and stores them NFC, but a pasted or keyboard-composed accent can arrive as + * NFD, which compares unequal byte-wise. + */ +export function normalizeForMatch(value) { + return String(value ?? '').normalize('NFC').trim().toLowerCase().replace(/\s+/g, ' '); +} + +/** + * Pinterest path prefixes that are site routes, not usernames. Without this a pin URL parses as + * the board `pin/` and fails with a confusing "could not resolve board". + */ +const RESERVED_PATH_ROOTS = new Set(['pin', 'search', 'ideas', 'today', 'settings', '_saved', 'news_hub', 'business']); + +/** Percent-decode one path segment; a malformed escape is left as-is rather than throwing. */ +function decodeSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +/** + * Parse a board URL or / into { username, slug, path }. + * Returns null for anything else (e.g. a bare board id, which needs an API lookup). + */ +export function tryParseBoardRef(raw) { + const trimmed = String(raw ?? '').trim(); + if (!trimmed) return null; + let pathname = trimmed; + if (/^https?:\/\//i.test(trimmed)) { + try { + pathname = new URL(trimmed).pathname; + } catch { + throw new ArgumentError(`Invalid board URL: ${trimmed}`, 'Use a full board URL like https://www.pinterest.com/janedoe/my-board/'); + } + } + // Pinterest percent-encodes non-ASCII slugs in the URLs it hands out; the API wants them decoded. + const parts = pathname.split('/').filter(Boolean).map(decodeSegment); + if (parts.length < 2) return null; + const [username, slug] = parts; + if (RESERVED_PATH_ROOTS.has(username.toLowerCase())) { + throw new ArgumentError( + `"${raw}" is a Pinterest /${username}/ URL, not a board`, + username.toLowerCase() === 'pin' + ? 'Pass the board this pin lives on, e.g. janedoe/my-board (`opencli pinterest pin ` reports it)' + : 'Pass /, a board URL, or a numeric board id', + ); + } + return { username, slug, path: `/${username}/${slug}/` }; +} + +/** Normalize a username or profile URL to a bare username (Pinterest has no @-handles). */ +export function parseUsername(raw) { + let value = String(raw ?? '').trim(); + if (!value) throw new ArgumentError('username is required', 'Pass a Pinterest username or profile URL, e.g. janedoe'); + if (/^https?:\/\//i.test(value)) { + try { + value = decodeSegment(new URL(value).pathname.split('/').filter(Boolean)[0] || ''); + } catch { + throw new ArgumentError(`Invalid profile URL: ${raw}`, 'Use a full profile URL like https://www.pinterest.com/janedoe/'); + } + } + value = value.replace(/^\/+|\/+$/g, ''); + if (!value) throw new ArgumentError(`Not a username: "${raw}"`, 'Pass the bare username, e.g. janedoe'); + if (value.includes('/')) { + throw new ArgumentError( + `Not a username: "${raw}"`, + 'This looks like a board or pin reference — pass just the username, e.g. janedoe', + ); + } + if (value.startsWith('@')) { + throw new ArgumentError(`Pinterest usernames have no "@": "${raw}"`, 'Drop the @ and use the bare username, e.g. janedoe'); + } + return value; +} + +/** Parse a bare pin id or a /pin// URL. */ +export function parsePinId(raw) { + const value = String(raw ?? '').trim(); + if (!value) throw new ArgumentError('pin id is required', 'Pass a pin id or /pin// URL, e.g. 1234567890123456'); + if (/^\d+$/.test(value)) return value; + const match = value.match(/\/pin\/(\d+)/); + if (match) return match[1]; + throw new ArgumentError(`Not a pin id or pin URL: "${raw}"`, 'Expected a numeric id or a /pin// URL, e.g. 1234567890123456'); +} + +/** Highest-resolution image URL available for a pin. */ +export function pickPinImage(images) { + if (!images || typeof images !== 'object') return ''; + for (const key of ['orig', '736x', '564x', '474x', '236x']) { + const candidate = images[key]; + if (candidate && candidate.url) return candidate.url; + } + return ''; +} + +/** Map a raw pin to the shared grid-row shape used by list commands. */ +export function toPinRow(pin) { + const id = String(pin.id ?? ''); + return { + pinId: id, + title: (pin.title || pin.grid_title || '').trim(), + description: (typeof pin.description === 'string' ? pin.description : '').trim(), + pinner: (pin.pinner && pin.pinner.username) || '', + board: (pin.board && pin.board.name) || '', + imageUrl: pickPinImage(pin.images), + url: id ? `${PINTEREST_BASE}/pin/${id}/` : '', + }; +} + +/** In-page fetch for a resource action; values are JSON-encoded so input can't break out. */ +function resourceFetchScript(url, body) { + return ` + (async () => { + // page.goto can resolve before Pinterest has set csrftoken, and posting without it is a + // 403. Wait for the cookie instead of firing a request that is guaranteed to be rejected. + const readCsrf = () => (document.cookie.match(/csrftoken=([^;]+)/) || [])[1] || ''; + let csrf = readCsrf(); + for (let i = 0; i < 30 && !csrf; i++) { + await new Promise((r) => setTimeout(r, 100)); + csrf = readCsrf(); + } + if (!csrf) return { __noCsrf: true }; + try { + const resp = await fetch(${JSON.stringify(url)}, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrf, + 'X-Pinterest-PWS-Handler': ${JSON.stringify(PWS_HANDLER)}, + }, + body: ${JSON.stringify(body)}, + }); + const text = await resp.text(); + if (!resp.ok) { + let apiMessage = ''; + try { + const rr = JSON.parse(text).resource_response; + apiMessage = (rr && ((rr.error && rr.error.message) || rr.message)) || ''; + } catch {} + return { __httpError: resp.status, message: apiMessage || text.slice(0, 200) }; + } + try { return JSON.parse(text); } catch { return { __malformed: true }; } + } catch (err) { + return { __fetchError: (err && err.message) || String(err) }; + } + })() + `; +} + +/** POST a resource action ('get' | 'create' | 'update' | 'delete'); returns { data, results, bookmark }. */ +export async function pinterestResourceFetch(page, resource, options, sourceUrl, action = 'get') { + const data = JSON.stringify({ options, context: {} }); + const body = `source_url=${encodeURIComponent(sourceUrl)}&data=${encodeURIComponent(data)}`; + const url = `/resource/${resource}/${action}/`; + + const raw = unwrapEvaluateResult(await page.evaluate(resourceFetchScript(url, body))); + + if (raw?.__fetchError) { + throw new CommandExecutionError(`Pinterest request failed: ${raw.__fetchError}`); + } + if (raw?.__noCsrf) { + throw new CommandExecutionError( + 'Pinterest did not set a csrftoken cookie for this page', + 'Open https://www.pinterest.com in Chrome (logged in) and retry', + ); + } + if (raw?.__httpError) { + const status = raw.__httpError; + // Reads work anonymously, so their 403 is a rejected request, not a login prompt; + // writes genuinely need login, so treat their 403 as auth too. + if (status === 401 || (WRITE_ACTIONS.has(action) && status === 403)) { + // Pinterest also answers 401 for writes it refuses on a logged-in session (e.g. editing the + // link of a scraped pin), so pass its own message through instead of only saying "log in". + throw new AuthRequiredError( + PINTEREST_DOMAIN, + raw.message + ? `Pinterest refused this write: ${raw.message}` + : 'This action requires being logged in to Pinterest in Chrome', + ); + } + const hint = status === 403 ? ' (missing or expired CSRF token — reload Pinterest in Chrome)' : ''; + // Surface Pinterest's own error text (from resource_response.error.message) when present. + const detail = raw.message ? `: ${raw.message}` : ''; + throw new CommandExecutionError(`Pinterest request failed (HTTP ${status})${detail}${hint}`); + } + if (!raw || typeof raw !== 'object' || raw.__malformed) { + throw new CommandExecutionError('Pinterest request returned malformed JSON payload'); + } + const resourceResponse = raw.resource_response; + if (!resourceResponse || typeof resourceResponse !== 'object') { + throw new CommandExecutionError('Pinterest request returned malformed API payload'); + } + const payload = resourceResponse.data; + const results = Array.isArray(payload) + ? payload + : (payload && Array.isArray(payload.results) ? payload.results : []); + return { data: payload, results, bookmark: resourceResponse.bookmark || null }; +} + +/** POST a /create/ mutation; returns the created resource_response.data. */ +export async function pinterestResourceCreate(page, resource, options, sourceUrl) { + const { data } = await pinterestResourceFetch(page, resource, options, sourceUrl, 'create'); + return data; +} + +/** POST an /update/ mutation; returns the updated resource_response.data. */ +export async function pinterestResourceUpdate(page, resource, options, sourceUrl) { + const { data } = await pinterestResourceFetch(page, resource, options, sourceUrl, 'update'); + return data; +} + +/** POST a /delete/ mutation; resolves once the request succeeds (data is usually null). */ +export async function pinterestResourceDelete(page, resource, options, sourceUrl) { + const { data } = await pinterestResourceFetch(page, resource, options, sourceUrl, 'delete'); + return data; +} + +/** + * Resolve a board ref to its numeric id (the write endpoints need the id, not the slug). + * Pass `preloaded` — the board `resolveBoardTarget` already fetched — to skip the extra request. + */ +export async function resolveBoardId(page, username, slug, path, preloaded = null) { + const board = preloaded && preloaded.id + ? preloaded + : (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 do you own it?)`); + } + return { boardId: String(boardId), board }; +} + +/** + * Resolve a board argument to { username, slug, path }, accepting a full board URL, + * /, or a numeric board id (which BoardResource can look up directly). + * Display names are deliberately not accepted: a name cannot say whose board it is. + */ +export async function resolveBoardTarget(page, raw) { + const trimmed = String(raw ?? '').trim(); + if (!trimmed) throw new ArgumentError('board is required', 'Pass /, a board URL, or a numeric board id'); + + const direct = tryParseBoardRef(trimmed); + if (direct) return { ...direct, board: null }; + + if (!/^\d+$/.test(trimmed)) { + throw new ArgumentError( + `Not a board reference: "${trimmed}"`, + 'Expected /, a board URL, or a numeric board id (from `board-pins`/`user-boards`)', + ); + } + + await page.goto(`${PINTEREST_BASE}/`); + const { data: board } = await pinterestResourceFetch( + page, + 'BoardResource', + { board_id: trimmed, field_set_key: 'detailed' }, + '/', + ); + const url = board && board.url; + if (!url) { + throw new ArgumentError(`No board with id "${trimmed}"`, 'Check the id with `opencli pinterest user-boards `'); + } + const parts = decodeSegment(url).split('/').filter(Boolean); + if (parts.length < 2) { + throw new CommandExecutionError(`Board ${trimmed} returned an unusable url: ${url}`); + } + // Hand the fetched board back so callers do not re-request what we already have. + return { username: parts[0], slug: parts[1], path: `/${parts[0]}/${parts[1]}/`, board }; +} + +/** + * Resolve a --section value against the board's real sections, accepting an id or a slug. + * Pinterest ignores an unknown section silently, so an unmatched value must fail here. + * Returns { sectionId, title, slug }. + */ +export async function resolveSection(page, boardId, sectionValue, sourceUrl) { + const wanted = String(sectionValue ?? '').trim(); + const { results } = await pinterestResourceFetch(page, 'BoardSectionsResource', { board_id: String(boardId) }, sourceUrl); + const sections = results.filter((section) => section && section.id); + if (sections.length === 0) { + throw new ArgumentError(`Board has no sections, so --section "${wanted}" cannot be used`, 'Create one first with `opencli pinterest board-section-create`'); + } + const folded = normalizeForMatch(wanted); + const match = sections.find((section) => String(section.id) === wanted) + || sections.find((section) => normalizeForMatch(section.slug) === folded); + if (!match) { + const available = sections.map((section) => `${section.slug || '(no slug)'} (${section.id})`).join(', '); + throw new ArgumentError(`No section matching "${wanted}" on this board`, `Pass a section slug or id — available: ${available}`); + } + return { sectionId: String(match.id), title: (match.title || '').trim(), slug: match.slug || '' }; +} + +/** + * Move an existing pin into a board section. + * The create endpoints (PinResource/create, RepinResource/create) accept a section key, answer + * HTTP 200, and file the pin at the board root anyway — only PinResource/update honours it. So + * callers that create a pin have to follow up with this second request. + */ +export async function movePinToSection(page, pinId, boardId, sectionId, sourceUrl) { + try { + await pinterestResourceUpdate( + page, + 'PinResource', + { id: String(pinId), board_id: String(boardId), board_section_id: String(sectionId) }, + sourceUrl, + ); + } catch (err) { + throw new CommandExecutionError( + `Pin ${pinId} was created but could not be moved into section ${sectionId}: ${err.message}`, + `The pin is on the board root — move it with \`opencli pinterest pin-update ${pinId} --board --section ${sectionId}\``, + ); + } +} + +/** Page a feed via bookmark until `limit` rows; mapItem returns a row or null to skip. */ +export async function collectResults( + page, + { resource, baseOptions, sourceUrl, limit, keyField, mapItem, pageSize = 25, maxPages = 12 }, +) { + const rows = []; + const seen = new Set(); + let bookmark = null; + + for (let pageIndex = 0; pageIndex < maxPages && rows.length < limit; pageIndex++) { + const options = { ...baseOptions, page_size: pageSize }; + if (bookmark) options.bookmarks = [bookmark]; + + const { results, bookmark: next } = await pinterestResourceFetch(page, resource, options, sourceUrl); + + for (const item of results) { + const row = mapItem(item); + if (!row) continue; + const key = String(row[keyField] ?? ''); + if (!key || seen.has(key)) continue; + seen.add(key); + rows.push(row); + if (rows.length >= limit) break; + } + + if (!next || next === bookmark || next === '-end-') break; + bookmark = next; + } + + return rows; +} + +/** collectResults specialised to organic pins (drops ads and non-pin items). */ +export function collectPins(page, opts) { + return collectResults(page, { + ...opts, + keyField: 'pinId', + mapItem: (pin) => (pin && pin.type === 'pin' && !pin.is_promoted ? toPinRow(pin) : null), + }); +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fac6e79fa..3395da9d5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -103,6 +103,7 @@ export default defineConfig({ { text: 'Indeed', link: '/adapters/browser/indeed' }, { text: 'Upwork', link: '/adapters/browser/upwork' }, { text: 'Instagram', link: '/adapters/browser/instagram' }, + { text: 'Pinterest', link: '/adapters/browser/pinterest' }, { text: 'JD.com', link: '/adapters/browser/jd' }, { text: 'Medium', link: '/adapters/browser/medium' }, { text: 'Mercury', link: '/adapters/browser/mercury' }, diff --git a/docs/adapters/browser/pinterest.md b/docs/adapters/browser/pinterest.md new file mode 100644 index 000000000..3e29e8ec5 --- /dev/null +++ b/docs/adapters/browser/pinterest.md @@ -0,0 +1,117 @@ +# Pinterest + +**Mode**: 🔐 Browser · **Domain**: `pinterest.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli pinterest search-pins` | Search pins on Pinterest | +| `opencli pinterest search-boards` | Search for boards on Pinterest | +| `opencli pinterest search-users` | Search for users on Pinterest | +| `opencli pinterest pin` | Get details of a Pinterest pin | +| `opencli pinterest user` | Get a Pinterest user's public profile stats | +| `opencli pinterest user-pins` | List pins created by a Pinterest user | +| `opencli pinterest user-boards` | List a user's boards | +| `opencli pinterest board-pins` | List pins inside a Pinterest board | +| `opencli pinterest board-sections` | List the sections inside a Pinterest board | +| `opencli pinterest download` | Download a pin's original image to disk | +| `opencli pinterest save` | Save a pin to your profile or a board | +| `opencli pinterest pin-create` | Create a pin from a remote image URL onto a board | +| `opencli pinterest pin-update` | Update a pin's title, description, link, or board | +| `opencli pinterest pin-delete` | Delete one of your own pins | +| `opencli pinterest board-create` | Create a new board on your account | +| `opencli pinterest board-update` | Update the name, description, or privacy of your board | +| `opencli pinterest board-delete` | Delete one of your own boards | +| `opencli pinterest board-section-create` | Create a section inside one of your boards | +| `opencli pinterest board-section-delete` | Delete a section from one of your boards | + +## Usage Examples + +### Read + +```bash +# Search +opencli pinterest search-pins "nordic interior" --limit 10 +opencli pinterest search-boards "coffee" --limit 10 +opencli pinterest search-users "coffee" --limit 10 + +# A user's profile, pins, and boards +opencli pinterest user janedoe +opencli pinterest user-pins janedoe --limit 10 +opencli pinterest user-boards janedoe --limit 50 --sort alphabetical + +# A board's pins and sections +opencli pinterest board-pins janedoe/my-board --limit 10 +opencli pinterest board-sections janedoe/my-board + +# A single pin, and downloading its original image +opencli pinterest pin 1234567890123456 +opencli pinterest download 1234567890123456 --output ~/Downloads/pins + +# JSON output +opencli pinterest search-pins "coffee" -f json +``` + +Boards are addressed by `/`, a board URL, or the numeric `boardId` from +`user-boards`; sections by their slug or id from `board-sections`. + +### Write + +```bash +# Save (repin) an existing pin — omit --board to save to your profile +opencli pinterest save 1234567890123456 +opencli pinterest save 1234567890123456 --board janedoe/my-board --section my-section + +# Create a pin from an image URL +opencli pinterest pin-create "https://example.com/image.jpg" \ + --board janedoe/my-board --title "My pin" --link "https://example.com" + +# Update a pin (text, or move it to another board/section) +opencli pinterest pin-update 1234567890123456 --title "New title" +opencli pinterest pin-update 1234567890123456 --board janedoe/other-board --section my-section + +# Boards and sections +opencli pinterest board-create "My board" --description "..." --privacy secret +opencli pinterest board-update janedoe/my-board --name "Renamed" --privacy public +opencli pinterest board-section-create janedoe/my-board --title "My section" +``` + +### Destructive commands + +Deletes require `--confirm`. Without it the command prints what it *would* delete and exits +non-zero without touching anything. + +```bash +opencli pinterest pin-delete 1234567890123456 # preview only +opencli pinterest pin-delete 1234567890123456 --confirm # actually delete + +opencli pinterest board-delete janedoe/my-board # preview (shows the board's pin count) +opencli pinterest board-delete janedoe/my-board --confirm # deletes the board *and its pins* + +opencli pinterest board-section-delete janedoe/my-board --section my-section --confirm +``` + +## Notes + +- **Reads work without logging in.** Search, pin, user, and board commands run fine on a + logged-out Chrome session. Only the write commands need a Pinterest login; if the CSRF token + is missing or stale, reload Pinterest in Chrome and retry. +- **`pin-create` needs a fetchable image URL.** Pinterest scrapes the URL server-side, so it + must return the image itself — HTML page URLs and hotlink-protected hosts fail with + `HTTP 400 ... check the URL`. Pinterest also derives its own description for scraped pins, + so `--description` may be ignored. +- **Sections are only settable by a follow-up move.** Pinterest's create endpoints accept a + section key, answer `HTTP 200`, and file the pin at the board root anyway; only + `PinResource/update` honours it. So `save --section` and `pin-create --section` create the pin + and then move it. If that second step fails, the command errors with the id of the pin it + already created rather than reporting success. +- **`--description ""` clears the field; `--link` may be refused.** Omitting a flag leaves the + field alone, and passing an empty string clears it. Pinterest rejects link edits on pins it + scraped for you (`Pinterest refused this write: 你無權存取該資源`), so `--link` only works on + pins whose link is yours. +- **`save` without `--board`** posts a boardless repin, which Pinterest files under your + profile's "Quick saves" board. `pin-create` cannot do this — creating a *new* pin always + requires a board. +- **`user`'s `followingCount` includes topics.** It is Pinterest's own total, covering followed + people *and* followed topics; `interestFollowingCount` is the topic-only part. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 3c3907dac..31dc7e1f6 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -58,6 +58,7 @@ Run `opencli list` for the live registry. | **[indeed](./browser/indeed.md)** | `search` `job` | 🍪 Browser | | **[upwork](./browser/upwork.md)** | `search` `feed` `detail` | 🔐 Browser | | **[instagram](./browser/instagram.md)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | 🔐 Browser | +| **[pinterest](./browser/pinterest.md)** | `search-pins` `search-boards` `search-users` `pin` `user` `user-pins` `user-boards` `board-pins` `board-sections` `download` `save` `pin-create` `pin-update` `pin-delete` `board-create` `board-update` `board-delete` `board-section-create` `board-section-delete` | 🔐 Browser | | **[medium](./browser/medium.md)** | `feed` `search` `user` | 🔐 Browser | | **[mubu](./browser/mubu.md)** | `doc` `docs` `notes` `recent` `search` | 🔐 Browser | | **[sinablog](./browser/sinablog.md)** | `hot` `search` `article` `user` | 🔐 Browser |