diff --git a/README.md b/README.md index 811d068bc..e84c10236 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | | **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` | +| **1point3acres** | `hot` `threads` `forums` `posts` `search` | | **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` | | **1688** | `search` `item` `assets` `download` `store` | | **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` | @@ -141,7 +142,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **xianyu** | `search` `item` `chat` | | **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | -79+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** +80+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)** ## CLI Hub diff --git a/README.zh-CN.md b/README.zh-CN.md index d9b534a3c..a591d6923 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -131,6 +131,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参 |------|------|------| | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 浏览器 | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 浏览器 | +| **1point3acres** | `hot` `threads` `forums` `posts` `search` | 公开 / 浏览器 | | **tieba** | `hot` `posts` `search` `read` | 浏览器 | | **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 | | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 | @@ -205,7 +206,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参 | **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 | | **yuanbao** | `new` `ask` | 浏览器 | -79+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)** +80+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)** ### 外部 CLI 枢纽 diff --git a/clis/1point3acres/forums.yaml b/clis/1point3acres/forums.yaml new file mode 100644 index 000000000..e5f7ea14f --- /dev/null +++ b/clis/1point3acres/forums.yaml @@ -0,0 +1,30 @@ +site: 1point3acres +name: forums +description: 1Point3Acres forum list +domain: api.1point3acres.com +strategy: public +browser: false + +args: + limit: + type: int + default: 200 + description: Number of forums + +pipeline: + - fetch: + url: https://api.1point3acres.com/api/forums + + - select: forums + + - map: + fid: ${{ item.fid }} + name: ${{ item.name }} + type: ${{ item.type }} + parent_fid: ${{ item.fup }} + display_order: ${{ item.displayorder }} + today_posts: ${{ item.todayposts }} + + - limit: ${{ args.limit }} + +columns: [fid, name, type, parent_fid, display_order, today_posts] diff --git a/clis/1point3acres/hot.yaml b/clis/1point3acres/hot.yaml new file mode 100644 index 000000000..bef11f147 --- /dev/null +++ b/clis/1point3acres/hot.yaml @@ -0,0 +1,39 @@ +site: 1point3acres +name: hot +description: 1Point3Acres hot topics +domain: api.1point3acres.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of topics + page: + type: int + default: 1 + description: Page number + +pipeline: + - fetch: + url: https://api.1point3acres.com/api/threads + params: + ps: ${{ args.limit }} + page: ${{ args.page }} + + - select: threads + + - map: + rank: ${{ index + 1 }} + tid: ${{ item.tid }} + title: ${{ item.subject }} + forum: ${{ item.forum_name }} + fid: ${{ item.fid }} + author: ${{ item.author }} + replies: ${{ item.replies }} + views: ${{ item.views }} + heats: ${{ item.heats }} + url: https://www.1point3acres.com/bbs/thread-${{ item.tid }}-1-1.html + +columns: [rank, title, forum, author, replies, views, heats, url] diff --git a/clis/1point3acres/posts.ts b/clis/1point3acres/posts.ts new file mode 100644 index 000000000..cf7e93425 --- /dev/null +++ b/clis/1point3acres/posts.ts @@ -0,0 +1,114 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import type { IPage } from '@jackwener/opencli/types'; + +type PostRow = { + floor: number; + pid: string; + author: string; + created_at: string; + content: string; +}; + +function requirePositiveInt(value: unknown, fallback: number): number { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +cli({ + site: '1point3acres', + name: 'posts', + description: '1Point3Acres thread posts (login required)', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'tid', type: 'str', required: true, positional: true, help: 'Thread ID' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of posts' }, + { name: 'page', type: 'int', default: 1, help: 'Page number' }, + ], + columns: ['floor', 'author', 'created_at', 'content'], + func: async (page: IPage | null, args) => { + if (!page) throw new CommandExecutionError('Browser session required for 1point3acres posts'); + + const tid = String(args.tid ?? '').trim(); + if (!/^\d+$/.test(tid)) { + throw new CommandExecutionError(`Invalid 1Point3Acres thread ID: ${tid}`); + } + + const limit = requirePositiveInt(args.limit, 20); + const pageNum = requirePositiveInt(args.page, 1); + await page.goto(`https://www.1point3acres.com/bbs/thread-${tid}-${pageNum}-1.html`); + + const result = await page.evaluate(`(() => { + const limit = ${JSON.stringify(limit)}; + const pageNum = ${JSON.stringify(pageNum)}; + const clean = (value) => String(value || '') + .replace(/\\s+/g, ' ') + .replace(/\\bwindow\\.[^\\n]+/g, '') + .trim(); + const textOf = (root, selector) => { + const el = root.querySelector(selector); + return el ? clean(el.textContent) : ''; + }; + const sanitizeAuthor = (value) => clean(value) + .replace(/^[^\\w\\u4e00-\\u9fff]+\\s*/, '') + .replace(/\\s*发消息\\s*$/, '') + .split('|')[0] + .replace(/\\s+\\d+\\s*(?:秒|分钟|小时|天)前.*$/, '') + .trim(); + const findAuthor = (post) => { + const selectors = [ + '.pls .xw1 a', + '.pls .xw1', + '.authi a[href*="space-uid"]', + '.authi a[href*="mod=space"]', + '.authi a[href*="/next/contact-post/"]', + '.authi' + ]; + for (const selector of selectors) { + const text = textOf(post, selector); + const author = sanitizeAuthor(text); + if (author && !/^#?$/.test(author)) return author; + } + return ''; + }; + const findCreatedAt = (post) => { + const authText = textOf(post, '.pti .authi, .authi'); + const match = authText.match(/(\\d+\\s*(?:秒|分钟|小时|天)前|\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}[^|\\n]*)/); + return match ? clean(match[1]) : ''; + }; + const rows = []; + for (const post of Array.from(document.querySelectorAll('div[id^="post_"]'))) { + const rawId = post.id || ''; + if (!/^post_\\d+$/.test(rawId)) continue; + const pid = rawId.slice('post_'.length); + const contentEl = post.querySelector('#postmessage_' + pid) || post.querySelector('.t_f') || post.querySelector('.pcb'); + const content = contentEl ? clean(contentEl.textContent) : ''; + if (!content) continue; + rows.push({ + floor: (pageNum - 1) * limit + rows.length + 1, + pid, + author: findAuthor(post), + created_at: findCreatedAt(post), + content: content.slice(0, 500) + }); + if (rows.length >= limit) break; + } + const permissionText = document.body?.innerText || ''; + return { rows, permissionText: permissionText.slice(0, 5000) }; + })()`) as { rows?: PostRow[]; permissionText?: string }; + + if (result.permissionText?.includes('无法进行此操作')) { + throw new CommandExecutionError( + '1Point3Acres refused access for this account.', + 'The current 1Point3Acres user group does not have permission to read this thread.', + ); + } + + if (!result.rows?.length) { + throw new EmptyResultError('1point3acres posts', 'No posts found. Check the thread ID and account permissions.'); + } + + return result.rows; + }, +}); diff --git a/clis/1point3acres/search.ts b/clis/1point3acres/search.ts new file mode 100644 index 000000000..2ec1afdf1 --- /dev/null +++ b/clis/1point3acres/search.ts @@ -0,0 +1,93 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import type { IPage } from '@jackwener/opencli/types'; + +type SearchThread = { + tid?: number | string; + subject?: string; + title?: string; + forum_name?: string; + forum?: string; + author?: string; + username?: string; + replies?: number | string; + views?: number | string; +}; + +type SearchPayload = { + errno?: number; + msg?: string; + threads?: SearchThread[]; +}; + +function positiveInt(value: unknown, fallback: number): number { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +cli({ + site: '1point3acres', + name: 'search', + description: '1Point3Acres search (login and search permission required)', + strategy: Strategy.COOKIE, + browser: true, + args: [ + { name: 'query', type: 'str', required: true, positional: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of results' }, + { name: 'page', type: 'int', default: 1, help: 'Page number' }, + ], + columns: ['rank', 'title', 'forum', 'author', 'replies', 'views', 'url'], + func: async (page: IPage | null, args) => { + if (!page) throw new CommandExecutionError('Browser session required for 1point3acres search'); + + const query = String(args.query ?? '').trim(); + if (!query) throw new CommandExecutionError('1Point3Acres search query cannot be empty'); + + const limit = positiveInt(args.limit, 20); + const pageNum = positiveInt(args.page, 1); + + await page.goto('https://api.1point3acres.com/api/threads'); + const payload = await page.evaluate(`(async () => { + const url = 'https://api.1point3acres.com/api/search' + + '?keyword=' + encodeURIComponent(${JSON.stringify(query)}) + + '&page=' + encodeURIComponent(${JSON.stringify(pageNum)}) + + '&ps=' + encodeURIComponent(${JSON.stringify(limit)}); + const res = await fetch(url, { credentials: 'include' }); + const text = await res.text(); + let body; + try { body = JSON.parse(text); } catch { body = { errno: -1, msg: text.slice(0, 200) }; } + return { status: res.status, body }; + })()`) as { status?: number; body?: SearchPayload }; + + if (payload.status && payload.status >= 400) { + throw new CommandExecutionError(`1Point3Acres search failed with HTTP ${payload.status}`); + } + if (payload.body?.errno && payload.body.errno !== 0) { + throw new CommandExecutionError(`1Point3Acres search failed: ${payload.body.msg ?? payload.body.errno}`); + } + + const threads = payload.body?.threads ?? []; + if (threads.length === 0) { + await page.goto(`https://www.1point3acres.com/bbs/search.php?mod=forum&searchsubmit=yes&kw=${encodeURIComponent(query)}`); + const permissionText = await page.evaluate('() => document.body?.innerText?.slice(0, 5000) || ""') as string; + if (permissionText.includes('无法进行此操作') || permissionText.includes('所在的用户组')) { + throw new CommandExecutionError( + '1Point3Acres search is not available for the current account.', + 'The logged-in account user group does not have permission to use site search yet.', + ); + } + throw new EmptyResultError('1point3acres search', `No threads found for query "${query}".`); + } + + return threads.slice(0, limit).map((item, index) => ({ + rank: index + 1, + tid: item.tid, + title: item.subject || item.title || '', + forum: item.forum_name || item.forum || '', + author: item.author || item.username || '', + replies: item.replies ?? '', + views: item.views ?? '', + url: item.tid ? `https://www.1point3acres.com/bbs/thread-${item.tid}-1-1.html` : '', + })); + }, +}); diff --git a/clis/1point3acres/threads.yaml b/clis/1point3acres/threads.yaml new file mode 100644 index 000000000..cc61b4f11 --- /dev/null +++ b/clis/1point3acres/threads.yaml @@ -0,0 +1,39 @@ +site: 1point3acres +name: threads +description: 1Point3Acres hot thread list +domain: api.1point3acres.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of threads + page: + type: int + default: 1 + description: Page number + +pipeline: + - fetch: + url: https://api.1point3acres.com/api/threads + params: + ps: ${{ args.limit }} + page: ${{ args.page }} + + - select: threads + + - map: + rank: ${{ index + 1 }} + tid: ${{ item.tid }} + title: ${{ item.subject }} + forum: ${{ item.forum_name }} + fid: ${{ item.fid }} + author: ${{ item.author }} + replies: ${{ item.replies }} + views: ${{ item.views }} + heats: ${{ item.heats }} + url: https://www.1point3acres.com/bbs/thread-${{ item.tid }}-1-1.html + +columns: [rank, title, forum, author, replies, views, heats, url] diff --git a/docs/adapters/browser/1point3acres.md b/docs/adapters/browser/1point3acres.md new file mode 100644 index 000000000..831f79c0f --- /dev/null +++ b/docs/adapters/browser/1point3acres.md @@ -0,0 +1,47 @@ +# 1Point3Acres + +**Mode**: 🔐 Browser · **Domain**: `1point3acres.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli 1point3acres hot` | Hot forum topics | +| `opencli 1point3acres threads` | Hot thread list from the public API | +| `opencli 1point3acres forums` | Public forum list | +| `opencli 1point3acres posts ` | Thread posts (auth required) | +| `opencli 1point3acres search ` | Search threads (auth required) | + +## Usage Examples + +```bash +# Hot topics +opencli 1point3acres hot --limit 10 + +# Equivalent public API thread list +opencli 1point3acres threads --limit 10 --page 1 + +# List forum IDs and names +opencli 1point3acres forums + +# Read thread posts (requires login) +opencli 1point3acres posts 1171864 --limit 20 + +# Search (requires login) +opencli 1point3acres search "USC CS" + +# JSON output +opencli 1point3acres hot --limit 10 -f json +``` + +## Prerequisites + +- `hot`, `threads`, and `forums`: no browser or login required. +- `posts` and `search`: Chrome running with the [Browser Bridge extension](/guide/browser-bridge) installed and logged into `1point3acres.com`. + +## Notes + +- Public commands use `https://api.1point3acres.com/api/forums` and `https://api.1point3acres.com/api/threads`. +- `posts` uses `POST https://api.1point3acres.com/api/threads//posts`. +- `search` uses `GET https://api.1point3acres.com/api/search`. +- The `fid` parameter on `threads` is not exposed because the upstream API currently ignores it. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 304d16d3d..41d519e6d 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -8,6 +8,7 @@ Run `opencli list` for the live registry. |------|----------|------| | **[twitter](./browser/twitter)** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `likes` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | 🔐 Browser | | **[reddit](./browser/reddit)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser | +| **[1point3acres](./browser/1point3acres)** | `hot` `threads` `forums` `posts` `search` | 🌐 / 🔐 | | **[tieba](./browser/tieba)** | `hot` `posts` `search` `read` | 🔐 Browser | | **[hupu](./browser/hupu)** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 🌐 / 🔐 | | **[bilibili](./browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |