diff --git a/README.md b/README.md index 2b41ee49e..dec10466b 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n | **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` | | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` | | **tieba** | `hot` `posts` `search` `read` | +| **hupu** | `hot` `search` `detail` `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` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` | | **amazon** | `bestsellers` `search` `product` `offer` `discussion` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5bc1e35c3..fe8a7127c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -129,6 +129,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` | 浏览器 | | **tieba** | `hot` `posts` `search` `read` | 浏览器 | +| **hupu** | `hot` `search` `detail` `reply` `like` `unlike` | 浏览器 | | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 | | **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | 桌面端 | diff --git a/docs/adapters/browser/hupu.md b/docs/adapters/browser/hupu.md new file mode 100644 index 000000000..972b201e5 --- /dev/null +++ b/docs/adapters/browser/hupu.md @@ -0,0 +1,53 @@ +# Hupu (虎扑) + +**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `bbs.hupu.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli hupu hot` | Read Hupu hot threads | +| `opencli hupu search ` | Search Hupu threads by keyword | +| `opencli hupu detail ` | Read one thread and optional hot replies | +| `opencli hupu reply ` | Reply to a thread or quote one reply | +| `opencli hupu like ` | Like one reply | +| `opencli hupu unlike ` | Cancel like on one reply | + +## Usage Examples + +```bash +# Hot threads +opencli hupu hot --limit 5 + +# Search threads +opencli hupu search 湖人 --limit 10 + +# Read one thread and include hot replies +opencli hupu detail 638234927 --replies true + +# Reply to the thread +opencli hupu reply 638234927 "hello from opencli" --topic_id 502 + +# Quote one hot reply by pid +opencli hupu reply 638234927 "replying to this comment" --topic_id 502 --quote_id 174908 + +# Like / unlike one reply +opencli hupu like 638234927 174908 --fid 4860 +opencli hupu unlike 638234927 174908 --fid 4860 + +# JSON output +opencli hupu detail 638234927 -f json +``` + +## Notes + +- `reply --topic_id` maps to Hupu's API `topicId`, for example `502` for Basketball News +- `reply --quote_id` is the quoted reply `pid` +- `like` / `unlike --fid` uses the forum ID from thread metadata +- `detail --replies true` appends top hot replies to the content field + +## Prerequisites + +- Chrome running and able to open `bbs.hupu.com` +- [Browser Bridge extension](/guide/browser-bridge) installed +- For `reply`, `like`, and `unlike`, a valid Hupu login session in Chrome is required diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 574604a64..7624289ce 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -9,6 +9,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 | | **[tieba](./browser/tieba)** | `hot` `posts` `search` `read` | 🔐 Browser | +| **[hupu](./browser/hupu)** | `hot` `search` `detail` `reply` `like` `unlike` | 🌐 / 🔐 | | **[bilibili](./browser/bilibili)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser | | **[zhihu](./browser/zhihu)** | `hot` `search` `question` `download` | 🔐 Browser | | **[xiaohongshu](./browser/xiaohongshu)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser | diff --git a/package-lock.json b/package-lock.json index b8b4bdb72..477446d66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -196,7 +196,6 @@ "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.49.2", "@algolia/requester-browser-xhr": "5.49.2", @@ -2164,7 +2163,6 @@ "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.15.2", "@algolia/client-abtesting": "5.49.2", @@ -2493,7 +2491,6 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -2622,7 +2619,6 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3094,7 +3090,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3483,7 +3478,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3513,7 +3507,6 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3647,7 +3640,6 @@ "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", @@ -4212,7 +4204,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4355,7 +4346,6 @@ "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", diff --git a/src/clis/hupu/detail.ts b/src/clis/hupu/detail.ts new file mode 100644 index 000000000..112c13ad0 --- /dev/null +++ b/src/clis/hupu/detail.ts @@ -0,0 +1,126 @@ +import { cli, Strategy } from '../../registry.js'; +import { getHupuThreadUrl, readHupuNextData, stripHtml } from './utils.js'; + +// JSON数据结构(对应Next.js的__NEXT_DATA__) +interface NextData { + props: Props; +} + +interface Props { + pageProps: PageProps; +} + +interface PageProps { + detail?: Detail; + detail_error_info?: DetailError; +} + +interface DetailError { + code: number; + message: string; +} + +interface Detail { + thread?: Thread; + lights?: ReplyData[]; +} + +interface Thread { + tid: string; + title: string; + content: string; + lights?: number; + replies?: number; + author?: Author; +} + +interface Author { + puname?: string; +} + +interface ReplyData { + pid: string; + author?: Author; + content: string; + allLightCount?: number; // 修复:正确的字段名 + created_at_format?: string; +} + +cli({ + site: 'hupu', + name: 'detail', + description: '获取虎扑帖子详情 (使用Next.js JSON数据)', + domain: 'bbs.hupu.com', + strategy: Strategy.PUBLIC, + browser: true, + navigateBefore: false, + args: [ + { + name: 'tid', + required: true, + positional: true, + help: '帖子ID(9位数字)' + }, + { + name: 'replies', + type: 'boolean', + default: false, + help: '是否包含热门回复' + } + ], + columns: ['title', 'author', 'content', 'replies', 'lights', 'url'], + func: async (page, kwargs) => { + const { tid, replies: includeReplies = false } = kwargs; + + const url = getHupuThreadUrl(tid).replace(/-1\.html$/, '.html'); + const data = await readHupuNextData(page, url, 'Read Hupu thread detail', { + expectedTid: String(tid), + }); + + // 检查错误信息(只有当code不是200时才报错) + const errorInfo = data.props.pageProps.detail_error_info; + if (errorInfo && errorInfo.code !== 200) { + throw new Error(`帖子访问失败: ${errorInfo.message} (code: ${errorInfo.code})`); + } + + // 获取帖子信息 + const thread = data.props.pageProps.detail?.thread; + if (!thread) { + throw new Error('帖子不存在或已被删除'); + } + + const authorName = thread.author?.puname || '未知作者'; + const content = stripHtml(thread.content); + const contentPreview = content.length > 300 ? content.substring(0, 300) + '...' : content; + + // 构建结果 + const result: any = { + title: thread.title, + author: authorName, + content: contentPreview, + replies: thread.replies || 0, + lights: thread.lights || 0, + url: `https://bbs.hupu.com/${tid}.html` + }; + + // 如果需要包含回复,添加回复信息到内容中 + if (includeReplies) { + const replyList = data.props.pageProps.detail?.lights || []; + const topReplies = replyList.slice(0, 3); + + if (topReplies.length > 0) { + let replyText = '\n\n【热门回复】\n'; + topReplies.forEach((reply, index) => { + const userName = reply.author?.puname || '未知用户'; + const replyContent = stripHtml(reply.content).substring(0, 100); + const replyLights = reply.allLightCount || 0; // 修复:使用正确的字段名 + const replyTime = reply.created_at_format || '未知时间'; + replyText += `${index + 1}. ${userName} (亮${replyLights} ${replyTime}):\n ${replyContent}\n\n`; + }); + result.content = contentPreview + replyText; + } + } + + return [result]; + }, +}); diff --git a/src/clis/hupu/hot.yaml b/src/clis/hupu/hot.yaml new file mode 100644 index 000000000..264688e07 --- /dev/null +++ b/src/clis/hupu/hot.yaml @@ -0,0 +1,43 @@ +site: hupu +name: hot +description: 虎扑热门帖子 +domain: bbs.hupu.com + +args: + limit: + type: int + default: 20 + description: Number of hot posts + +pipeline: + - navigate: https://bbs.hupu.com/ + + - evaluate: | + (async () => { + // 从HTML中提取帖子信息(适配新的HTML结构) + const html = document.documentElement.outerHTML; + const posts = []; + + // 匹配当前虎扑页面结构的正则表达式 + // 结构: 标题 + const regex = /]*href="\/(\d{9})\.html"[^>]*>]*class="t-title"[^>]*>([^<]+)<\/span><\/a>/g; + let match; + + while ((match = regex.exec(html)) !== null && posts.length < ${{ args.limit }}) { + posts.push({ + tid: match[1], + title: match[2].trim() + }); + } + + return posts; + })() + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + url: https://bbs.hupu.com/${{ item.tid }}.html + + - limit: ${{ args.limit }} + +columns: [rank, title, url] diff --git a/src/clis/hupu/like.ts b/src/clis/hupu/like.ts new file mode 100644 index 000000000..74528664b --- /dev/null +++ b/src/clis/hupu/like.ts @@ -0,0 +1,76 @@ +import { CliError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import { postHupuJson } from './utils.js'; + +cli({ + site: 'hupu', + name: 'like', + description: '点赞虎扑回复 (需要登录)', + domain: 'bbs.hupu.com', + strategy: Strategy.COOKIE, // 需要Cookie认证 + navigateBefore: false, + args: [ + { + name: 'tid', + required: true, + positional: true, + help: '帖子ID(9位数字)' + }, + { + name: 'pid', + required: true, + positional: true, + help: '回复ID' + }, + { + name: 'fid', + required: true, + help: '板块ID(如278汽车区)' + } + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + const { tid, pid, fid } = kwargs; + + const url = 'https://bbs.hupu.com/pcmapi/pc/bbs/v1/reply/light'; + + // 构建请求体 + const body = { + tid, + pid, + puid: '', + fid, + shumei_id: '', + deviceid: '' + }; + + try { + const result = await postHupuJson(page, tid, url, body, 'Like Hupu reply'); + + // 处理响应 + if (result.code === 1) { + return [{ + status: '✅ 点赞成功', + message: '' + }]; + } else if (result.code === 0 && result.msg === '你已经点亮过这个回帖了') { + return [{ + status: '⚠️ 已经点赞过了', + message: result.msg || '' + }]; + } else if (result.code === 0) { + return [{ + status: '⚠️ 操作未执行', + message: result.msg || result.message || '' + }]; + } else { + throw new Error(`接口错误 code=${result.code}: ${result.msg || result.message}`); + } + + } catch (error) { + if (error instanceof CliError) throw error; + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`点赞失败: ${errorMessage}`); + } + }, +}); diff --git a/src/clis/hupu/reply.ts b/src/clis/hupu/reply.ts new file mode 100644 index 000000000..c08e2f3ab --- /dev/null +++ b/src/clis/hupu/reply.ts @@ -0,0 +1,76 @@ +import { CliError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import { postHupuJson } from './utils.js'; + +cli({ + site: 'hupu', + name: 'reply', + description: '回复虎扑帖子 (需要登录)', + domain: 'bbs.hupu.com', + strategy: Strategy.COOKIE, // 需要Cookie认证 + navigateBefore: false, + args: [ + { + name: 'tid', + required: true, + positional: true, + help: '帖子ID(9位数字)' + }, + { + name: 'topic_id', + required: true, + help: '板块ID,即接口中的 topicId(如 502 篮球资讯)' + }, + { + name: 'text', + required: true, + positional: true, + help: '回复内容' + }, + { + name: 'quote_id', + help: '被引用回复的 pid;填写后会以“回复某条热门回复”的方式发言' + } + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + const { tid, topic_id, text, quote_id } = kwargs; + + const url = 'https://bbs.hupu.com/pcmapi/pc/bbs/v1/createReply'; + + // 虎扑内容用

包裹 + const content = `

${text}

`; + + // 构建请求体 + const body: Record = { + topicId: topic_id, + content, + shumeiId: '', + deviceid: '', + tid + }; + + // 如果有引用回复ID,添加到请求体 + if (quote_id) { + body.quoteId = quote_id; + } + + try { + const result = await postHupuJson(page, tid, url, body, 'Reply to Hupu thread', 'reply'); + + if (result.code === 1) { + return [{ + status: '✅ 回复成功', + message: result.msg || result.message || '' + }]; + } else { + throw new Error(`接口错误 code=${result.code}: ${result.msg || result.message}`); + } + + } catch (error) { + if (error instanceof CliError) throw error; + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`回复失败: ${errorMessage}`); + } + }, +}); diff --git a/src/clis/hupu/search.ts b/src/clis/hupu/search.ts new file mode 100644 index 000000000..58c046b78 --- /dev/null +++ b/src/clis/hupu/search.ts @@ -0,0 +1,95 @@ +import { cli, Strategy } from '../../registry.js'; +import { decodeHtmlEntities, getHupuSearchUrl, readHupuSearchData, stripHtml } from './utils.js'; + +// 搜索结果数据结构 +interface SearchResult { + id: string; + title: string; + content?: string; + username?: string; + addTimeDisplay?: string; + replies?: string; + lights?: string; + recNum?: string; + forum_name?: string; + fid?: string; +} + +// 虎扑搜索响应数据结构 +interface HupuSearchResponse { + init?: { + redirect?: string; + }; + env?: string; + query?: { + q?: string; + page?: string; + }; + searchRes?: { + count: number; + totalPage: number; + type?: string; + data: SearchResult[]; + }; +} + +cli({ + site: 'hupu', + name: 'search', + description: '搜索虎扑帖子 (使用官方API)', + domain: 'bbs.hupu.com', + strategy: Strategy.PUBLIC, // 公开API,不需要Cookie + browser: true, + navigateBefore: false, + args: [ + { + name: 'query', + required: true, + positional: true, + help: '搜索关键词' + }, + { + name: 'page', + type: 'int', + default: 1, + help: '结果页码' + }, + { + name: 'limit', + type: 'int', + default: 20, + help: '返回结果数量' + }, + { + name: 'forum', + help: '板块ID过滤 (可选)' + }, + { + name: 'sort', + default: 'general', + help: '排序方式: general/createtime/replytime/light/reply' + } + ], + columns: ['rank', 'title', 'author', 'replies', 'lights', 'forum', 'url'], + func: async (page, kwargs) => { + const { query, page: pageNum = 1, limit = 20, forum, sort = 'general' } = kwargs; + const searchUrl = getHupuSearchUrl(query, pageNum, forum, sort); + const data = await readHupuSearchData(page, searchUrl, 'Search Hupu threads'); + + // 提取搜索结果 + const results = data.searchRes?.data || []; + + // 处理结果:清理HTML标签,解码HTML实体 + const processedResults = results.slice(0, Number(limit)).map((item, index) => ({ + rank: index + 1, + title: decodeHtmlEntities(stripHtml(item.title)), + author: item.username || '未知用户', + replies: item.replies || '0', + lights: item.lights || '0', + forum: item.forum_name || '未知板块', + url: `https://bbs.hupu.com/${item.id}.html` + })); + + return processedResults; + }, +}); diff --git a/src/clis/hupu/unlike.ts b/src/clis/hupu/unlike.ts new file mode 100644 index 000000000..cb9062830 --- /dev/null +++ b/src/clis/hupu/unlike.ts @@ -0,0 +1,76 @@ +import { CliError } from '../../errors.js'; +import { cli, Strategy } from '../../registry.js'; +import { postHupuJson } from './utils.js'; + +cli({ + site: 'hupu', + name: 'unlike', + description: '取消点赞虎扑回复 (需要登录)', + domain: 'bbs.hupu.com', + strategy: Strategy.COOKIE, // 需要Cookie认证 + navigateBefore: false, + args: [ + { + name: 'tid', + required: true, + positional: true, + help: '帖子ID(9位数字)' + }, + { + name: 'pid', + required: true, + positional: true, + help: '回复ID' + }, + { + name: 'fid', + required: true, + help: '板块ID(如278汽车区)' + } + ], + columns: ['status', 'message'], + func: async (page, kwargs) => { + const { tid, pid, fid } = kwargs; + + const url = 'https://bbs.hupu.com/pcmapi/pc/bbs/v1/reply/cancelLight'; + + // 构建请求体(与点赞相同) + const body = { + tid, + pid, + puid: '', + fid, + shumei_id: '', + deviceid: '' + }; + + try { + const result = await postHupuJson(page, tid, url, body, 'Unlike Hupu reply'); + + // 处理响应 + if (result.code === 1) { + return [{ + status: '✅ 取消点赞成功', + message: '' + }]; + } else if (result.code === 0 && result.msg === '你还没有点亮过这个回帖') { + return [{ + status: '⚠️ 你还没点赞过', + message: result.msg || '' + }]; + } else if (result.code === 0) { + return [{ + status: '⚠️ 操作未执行', + message: result.msg || result.message || '' + }]; + } else { + throw new Error(`接口错误 code=${result.code}: ${result.msg || result.message}`); + } + + } catch (error) { + if (error instanceof CliError) throw error; + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`取消点赞失败: ${errorMessage}`); + } + }, +}); diff --git a/src/clis/hupu/utils.ts b/src/clis/hupu/utils.ts new file mode 100644 index 000000000..7d816f7f2 --- /dev/null +++ b/src/clis/hupu/utils.ts @@ -0,0 +1,381 @@ +import { AuthRequiredError, CommandExecutionError } from '../../errors.js'; +import type { IPage } from '../../types.js'; + +export interface HupuApiResponse { + code?: number; + msg?: string; + message?: string; +} + +interface BrowserFetchResult { + ok?: boolean; + status?: number; + data?: HupuApiResponse | null; + error?: string; +} + +interface BrowserDataResult { + ok?: boolean; + data?: T; + error?: string; +} + +export function stripHtml(html: string): string { + if (!html) return ''; + const decoded = html + .replace(/\\u003c/g, '<') + .replace(/\\u003e/g, '>') + .replace(/\\n/g, '\n') + .replace(/\\r/g, ''); + return decoded.replace(/<[^>]+>/g, '').trim(); +} + +export function decodeHtmlEntities(html: string): string { + if (!html) return ''; + return html.replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +export function getHupuThreadUrl(tid: unknown): string { + return `https://bbs.hupu.com/${encodeURIComponent(String(tid))}-1.html`; +} + +export function getHupuSearchUrl(query: unknown, page: unknown, forum?: unknown, sort?: unknown): string { + const searchParams = new URLSearchParams(); + searchParams.append('q', String(query)); + searchParams.append('page', String(page)); + + if (forum) { + searchParams.append('topicId', String(forum)); + } + + if (sort) { + searchParams.append('sortby', String(sort)); + } + + return `https://bbs.hupu.com/search?${searchParams.toString()}`; +} + +export async function readHupuNextData( + page: IPage, + url: string, + actionLabel: string, + options: { + expectedTid?: string; + timeoutMs?: number; + } = {}, +): Promise { + await page.goto(url); + + const result = await page.evaluate(` + (async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const expectedTid = ${JSON.stringify(options.expectedTid || '')}; + const timeoutMs = ${JSON.stringify(options.timeoutMs ?? 5000)}; + let lastSeenTid = ''; + let lastSeenHref = ''; + + const waitFor = async (predicate, limitMs = timeoutMs) => { + const start = Date.now(); + while (Date.now() - start < limitMs) { + if (predicate()) return true; + await wait(100); + } + return false; + }; + + const ready = await waitFor(() => { + const script = document.getElementById('__NEXT_DATA__'); + if (!script?.textContent) return false; + + lastSeenHref = location.href; + + try { + const parsed = JSON.parse(script.textContent); + const threadTid = parsed?.props?.pageProps?.detail?.thread?.tid; + lastSeenTid = typeof threadTid === 'string' ? threadTid : ''; + + if (!expectedTid) return true; + return threadTid === expectedTid; + } catch { + return false; + } + }); + if (!ready) { + return { + ok: false, + error: expectedTid + ? \`帖子数据未就绪或tid不匹配(expected=\${expectedTid}, actual=\${lastSeenTid || 'unknown'}, href=\${lastSeenHref || location.href})\` + : '无法找到帖子数据' + }; + } + + try { + const text = document.getElementById('__NEXT_DATA__')?.textContent || ''; + return { + ok: true, + data: JSON.parse(text) + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error) + }; + } + })() + `) as BrowserDataResult; + + if (!result || typeof result !== 'object' || !result.ok) { + throw new CommandExecutionError(`${actionLabel} failed: ${result?.error || 'invalid browser response'}`); + } + + return result.data as T; +} + +export async function readHupuSearchData(page: IPage, url: string, actionLabel: string): Promise { + await page.goto(url); + + const result = await page.evaluate(` + (async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (predicate, timeoutMs = 5000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return true; + await wait(100); + } + return false; + }; + + const extractFromScript = () => { + const marker = 'window.$$data='; + for (const script of Array.from(document.scripts)) { + const text = script.textContent || ''; + const dataIndex = text.indexOf(marker); + if (dataIndex === -1) continue; + + const jsonStart = dataIndex + marker.length; + let braceCount = 0; + let jsonEnd = jsonStart; + let inString = false; + let escapeNext = false; + + for (let i = jsonStart; i < text.length; i++) { + const char = text[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\\\') { + escapeNext = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (!inString) { + if (char === '{') { + braceCount++; + } else if (char === '}') { + braceCount--; + if (braceCount === 0) { + jsonEnd = i; + break; + } + } + } + } + + if (jsonEnd > jsonStart) { + return text.substring(jsonStart, jsonEnd + 1); + } + } + return ''; + }; + + const ready = await waitFor(() => { + return typeof window.$$data !== 'undefined' || Boolean(extractFromScript()); + }); + if (!ready) { + return { ok: false, error: '无法找到搜索数据' }; + } + + try { + if (typeof window.$$data !== 'undefined') { + return { + ok: true, + data: JSON.parse(JSON.stringify(window.$$data)) + }; + } + + const jsonString = extractFromScript(); + return { + ok: true, + data: JSON.parse(jsonString) + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error) + }; + } + })() + `) as BrowserDataResult; + + if (!result || typeof result !== 'object' || !result.ok) { + throw new CommandExecutionError(`${actionLabel} failed: ${result?.error || 'invalid browser response'}`); + } + + return result.data as T; +} + +function buildBrowserJsonPostScript( + apiUrl: string, + body: Record, + mode: 'default' | 'reply', +): string { + return ` + (async () => { + const url = ${JSON.stringify(apiUrl)}; + const payload = ${JSON.stringify(body)}; + const mode = ${JSON.stringify(mode)}; + const getCookie = (name) => document.cookie + .split('; ') + .find((item) => item.startsWith(name + '=')) + ?.slice(name.length + 1) || ''; + + const findThumbcacheValue = () => { + const rawEntry = document.cookie + .split('; ') + .find((item) => item.startsWith('.thumbcache_')); + if (rawEntry && rawEntry.includes('=')) { + const rawValue = rawEntry.slice(rawEntry.indexOf('=') + 1); + try { + return decodeURIComponent(rawValue); + } catch { + return rawValue; + } + } + + const storageKey = Object.keys(localStorage).find((key) => key.startsWith('.thumbcache_')); + if (!storageKey) return ''; + return localStorage.getItem(storageKey) || ''; + }; + + const resolveDefaultPayload = (input) => { + const next = { ...input }; + const sensorsRaw = decodeURIComponent(getCookie('sensorsdata2015jssdkcross') || ''); + let deviceid = ''; + try { + const sensors = JSON.parse(sensorsRaw); + deviceid = sensors?.props?.['$device_id'] || sensors?.distinct_id || ''; + } catch {} + + if ((next.puid === '' || next.puid == null) && getCookie('ua')) { + next.puid = getCookie('ua'); + } + if ((next.shumei_id === '' || next.shumei_id == null) && getCookie('smidV2')) { + next.shumei_id = getCookie('smidV2'); + } + if ((next.deviceid === '' || next.deviceid == null) && deviceid) { + next.deviceid = deviceid; + } + return next; + }; + + const resolveReplyPayload = (input) => { + const next = { ...input }; + const thumbcache = findThumbcacheValue(); + if ((next.shumeiId === '' || next.shumeiId == null) && thumbcache) { + next.shumeiId = thumbcache; + } + if ((next.deviceid === '' || next.deviceid == null) && thumbcache) { + next.deviceid = thumbcache; + } + return next; + }; + + const resolvedPayload = mode === 'reply' + ? resolveReplyPayload(payload) + : resolveDefaultPayload(payload); + + try { + const response = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(resolvedPayload) + }); + + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text ? { message: text } : null; + } + + return { + ok: response.ok, + status: response.status, + data + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error) + }; + } + })() + `; +} + +/** + * Execute authenticated Hupu JSON requests inside the browser page so + * cookies and the thread referer come from the live logged-in session. + */ +export async function postHupuJson( + page: IPage, + tid: unknown, + apiUrl: string, + body: Record, + actionLabel: string, + mode: 'default' | 'reply' = 'default', +): Promise { + const referer = getHupuThreadUrl(tid); + await page.goto(referer); + + const result = await page.evaluate( + buildBrowserJsonPostScript(apiUrl, body, mode), + ) as BrowserFetchResult; + + if (!result || typeof result !== 'object') { + throw new CommandExecutionError(`${actionLabel} failed: invalid browser response`); + } + + if (result.status === 401 || result.status === 403) { + throw new AuthRequiredError('bbs.hupu.com', `${actionLabel} failed: please log in to Hupu first`); + } + + if (result.error) { + throw new CommandExecutionError(`${actionLabel} failed: ${result.error}`); + } + + if (!result.ok) { + const detail = result.data?.msg || result.data?.message || `HTTP ${result.status ?? 'unknown'}`; + throw new CommandExecutionError(`${actionLabel} failed: ${detail}`); + } + + return result.data ?? {}; +}