├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ └── api.test.js ├── api.js ├── babel.config.js ├── index.js ├── package-lock.json ├── package.json ├── theme.js └── utils.js /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - name: Configure Git 21 | run: | 22 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 23 | git config --global user.name "github-actions[bot]" 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Publish to NPM 29 | run: npm run release:patch 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 依赖目录 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | 7 | # 环境变量 8 | .env 9 | .env.local 10 | .env.*.local 11 | 12 | # 编辑器目录和文件 13 | .idea/ 14 | .vscode/ 15 | *.swp 16 | *.swo 17 | .DS_Store 18 | 19 | # 日志文件 20 | logs/ 21 | *.log 22 | 23 | # 运行时数据 24 | pids/ 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # 可选 npm 缓存目录 30 | .npm 31 | 32 | # 可选 eslint 缓存 33 | .eslintcache 34 | 35 | preindex.js 36 | 37 | storage/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 moxuy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2EX Shell 2 | 3 | 一个基于 Node.js 的 V2EX 命令行工具,让你可以在终端中浏览 V2EX 的热门话题。 4 | 5 | ![image](https://cdn.jsdelivr.net/gh/jichangee/gallery@master/imgur/demo.gif?raw=true) 6 | 7 | ## 功能特性 8 | 9 | - 📱 浏览 V2EX 热门话题 10 | - 📄 分页显示,每页 10 条话题 11 | - 🔍 查看话题详情和回复 12 | - ⌨️ 交互式操作界面 13 | - 🎨 彩色输出,提升阅读体验 14 | 15 | ## 使用 16 | 17 | ```bash 18 | npx v2ex-shell 19 | ``` 20 | 21 | ## 安装 22 | 23 | 1. 克隆仓库: 24 | ```bash 25 | git clone https://github.com/jichangee/v2ex-shell.git 26 | cd v2ex-shell 27 | ``` 28 | 29 | 2. 安装依赖: 30 | ```bash 31 | npm install 32 | ``` 33 | 34 | 3. 全局安装(可选): 35 | ```bash 36 | npm link 37 | ``` 38 | 39 | ### 操作说明 40 | 41 | - 使用方向键(↑↓)选择话题或操作选项 42 | - 按回车键确认选择 43 | - 在话题列表中可以: 44 | - 选择具体话题查看详情 45 | - 选择"退出"结束程序 46 | - 在话题详情中可以: 47 | - 查看话题内容和所有回复 48 | - 选择"返回列表"回到话题列表 49 | - 选择"上一页"或"下一页"进行翻页 50 | - 选择"退出"结束程序 51 | 52 | ## 项目结构 53 | 54 | ``` 55 | v2ex-shell/ 56 | ├── index.js # 主程序入口 57 | ├── api.js # API 接口封装 58 | ├── package.json # 项目配置 59 | └── README.md # 项目文档 60 | ``` 61 | 62 | ## 贡献 63 | 64 | 欢迎提交 Issue 和 Pull Request! 65 | 66 | ## 许可证 67 | 68 | MIT -------------------------------------------------------------------------------- /__tests__/api.test.js: -------------------------------------------------------------------------------- 1 | import { getTopicsList, getTopicDetail } from '../api.js'; 2 | import axios from 'axios'; 3 | import cheerio from 'cheerio'; 4 | 5 | // Mock axios 6 | jest.mock('axios'); 7 | 8 | describe('API Tests', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | describe('getTopicsList', () => { 14 | it('应该正确获取和解析热主题', async () => { 15 | // Mock HTML response 16 | const mockHtml = ` 17 |
18 | 技术创意好玩Apple酷工作交易城市问与答最热全部R2VXNA 19 |
20 |
21 | 22 | 23 | 28 | 40 | 43 | 44 |
24 | 25 | 26 | 27 | 29 | 30 | Test Topic 31 | 32 |
33 | 34 | test • 35 | testuser • 36 | 1 小时前 • 37 | 最后回复来自 replyuser 38 | 39 |
41 | 5 42 |
45 |
46 | `; 47 | 48 | axios.get.mockResolvedValueOnce({ 49 | data: mockHtml, 50 | status: 200 51 | }); 52 | 53 | const result = await getTopicsList('/?tab=hot'); 54 | 55 | expect(result).toEqual({ 56 | currentTabTitle: '最热', 57 | tabs: [{ 58 | title: '技术', 59 | url: '/?tab=tech' 60 | }, { 61 | title: '创意', 62 | url: '/?tab=creative' 63 | }, { 64 | title: '好玩', 65 | url: '/?tab=play' 66 | }, { 67 | title: 'Apple', 68 | url: '/?tab=apple' 69 | }, { 70 | title: '酷工作', 71 | url: '/?tab=jobs' 72 | }, { 73 | title: '交易', 74 | url: '/?tab=deals' 75 | }, { 76 | title: '城市', 77 | url: '/?tab=city' 78 | }, { 79 | title: '问与答', 80 | url: '/?tab=qna' 81 | }, { 82 | title: '最热', 83 | url: '/?tab=hot' 84 | }, { 85 | title: '全部', 86 | url: '/?tab=all' 87 | }, { 88 | title: 'R2', 89 | url: '/?tab=r2' 90 | }, { 91 | title: 'VXNA', 92 | url: '/xna' 93 | }], 94 | topics: [{ 95 | title: 'Test Topic', 96 | url: 'https://www.v2ex.com/t/123', 97 | node: 'test', 98 | author: 'testuser', 99 | lastReplyTime: '1 小时前', 100 | lastReplyAuthor: 'replyuser', 101 | replies: '5', 102 | avatar: 'https://example.com/avatar.png' 103 | }] 104 | }); 105 | 106 | expect(axios.get).toHaveBeenCalledWith( 107 | 'https://www.v2ex.com/?tab=hot', 108 | expect.any(Object) 109 | ); 110 | }); 111 | 112 | it('should handle network errors', async () => { 113 | axios.get.mockRejectedValueOnce(new Error('Network error')); 114 | 115 | await expect(getTopicsList()).rejects.toThrow('获取热门话题失败: Network error'); 116 | }); 117 | }); 118 | 119 | describe('getTopicDetail', () => { 120 | it('应该正确获取和解析主题详细信息', async () => { 121 | const mockHtml = ` 122 |
Test content
123 |
124 |
user1
125 |
Reply 1
126 |
1 小时前
127 |
128 | `; 129 | 130 | axios.get.mockResolvedValueOnce({ 131 | data: mockHtml, 132 | status: 200 133 | }); 134 | 135 | const result = await getTopicDetail('https://www.v2ex.com/t/123'); 136 | 137 | expect(result).toEqual({ 138 | content: 'Test content', 139 | next: false, 140 | prev: false, 141 | replies: [{ 142 | author: 'user1', 143 | content: 'Reply 1', 144 | time: '1 小时前', 145 | "like": "", 146 | "op": false, 147 | }] 148 | }); 149 | 150 | expect(axios.get).toHaveBeenCalledWith( 151 | 'https://www.v2ex.com/t/123?p=1', 152 | expect.any(Object) 153 | ); 154 | }); 155 | 156 | it('should handle network errors', async () => { 157 | axios.get.mockRejectedValueOnce(new Error('Network error')); 158 | 159 | await expect(getTopicDetail('https://www.v2ex.com/t/123')) 160 | .rejects.toThrow('获取话题详情失败: Network error'); 161 | }); 162 | }); 163 | }); -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import * as cheerio from "cheerio"; 3 | import { convertHTMLContentToText } from "./utils.js"; 4 | 5 | const BASE_URL = "https://www.v2ex.com"; 6 | 7 | export async function getTopicsList(url) { 8 | try { 9 | const response = await axios.get(`${BASE_URL}${url}`, { 10 | headers: { 11 | "User-Agent": 12 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 13 | }, 14 | }); 15 | 16 | const $ = cheerio.load(response.data); 17 | const topics = []; 18 | const tabs = []; 19 | // 解析热门话题列表 20 | $(".cell.item").each((i, element) => { 21 | const $element = $(element); 22 | const topic = { 23 | title: $element.find(".topic-link").text().trim(), 24 | url: (BASE_URL + $element.find(".topic-link").attr("href")).split( 25 | "#" 26 | )[0], 27 | node: $element.find(".node").text().trim(), 28 | author: $element.find(".topic_info strong a").first().text().trim(), 29 | lastReplyTime: $element.find(".topic_info span[title]").text().trim(), 30 | lastReplyAuthor: $element 31 | .find(".topic_info strong a") 32 | .last() 33 | .text() 34 | .trim(), 35 | replies: $element.find(".count_livid").text().trim() || "0", 36 | avatar: $element.find(".avatar").attr("src"), 37 | }; 38 | topics.push(topic); 39 | }); 40 | 41 | $("#Tabs a").each((i, element) => { 42 | const $element = $(element); 43 | const tab = { 44 | title: $element.text().trim(), 45 | url: $element.attr("href"), 46 | }; 47 | tabs.push(tab); 48 | }); 49 | 50 | const currentTabTitle = $(".tab_current").text().trim(); 51 | 52 | return { 53 | topics, 54 | tabs, 55 | currentTabTitle, 56 | }; 57 | } catch (error) { 58 | throw new Error(`获取热门话题失败: ${error.message},${BASE_URL}${url}`); 59 | } 60 | } 61 | 62 | export async function getTopicDetail(url, page = 1) { 63 | try { 64 | const response = await axios.get(`${url}?p=${page}`, { 65 | headers: { 66 | "User-Agent": 67 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 68 | }, 69 | }); 70 | 71 | const $ = cheerio.load(response.data); 72 | const content = convertHTMLContentToText($(".topic_content").html()); 73 | 74 | const replies = []; 75 | 76 | $('.cell[id^="r_"]').each((i, element) => { 77 | const $element = $(element); 78 | const reply = { 79 | author: $element.find(".dark").text().trim(), 80 | content: convertHTMLContentToText($element.find(".reply_content").html()), 81 | like: $element.find("td .small.fade").text().trim(), 82 | time: $element.find(".ago").text().trim(), 83 | op: $element.find(".badge.op").text().trim() === "OP", 84 | }; 85 | replies.push(reply); 86 | }); 87 | const hasPagin = $(".ps_container").length > 0; 88 | const prev = hasPagin 89 | ? !$(".ps_container > table table td:first-child").hasClass("disable_now") 90 | : false; 91 | const next = hasPagin 92 | ? !$(".ps_container > table table td:last-child").hasClass("disable_now") 93 | : false; 94 | 95 | return { 96 | content, 97 | replies, 98 | prev, 99 | next, 100 | }; 101 | } catch (error) { 102 | throw new Error(`获取话题详情失败: ${error.message}`); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | presets: [['@babel/preset-env', {targets: {node: 'current'}}]], 3 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import ora from "ora"; 4 | import inquirer from "inquirer"; 5 | import { getTopicsList, getTopicDetail } from "./api.js"; 6 | import { wrapText, refReplyContent } from "./utils.js"; 7 | import { getTheme, themeChoices } from "./theme.js"; 8 | 9 | let theme = getTheme(); 10 | let lastReplys = []; 11 | async function displayTopicDetail(topic, currentPage) { 12 | const spinner = ora(theme.default("正在获取话题详情...")).start(); 13 | 14 | try { 15 | const detail = await getTopicDetail(topic.url, currentPage); 16 | if (currentPage === 1) { 17 | lastReplys = [...detail.replies]; 18 | } else { 19 | lastReplys = [...lastReplys, ...detail.replies]; 20 | } 21 | spinner.succeed(theme.default("获取成功!")); 22 | detail.replies = refReplyContent(detail.replies, lastReplys); 23 | const repliesLen = detail.replies.length; 24 | detail.replies.reverse().forEach((reply, index) => { 25 | console.log( 26 | theme.default( 27 | `${repliesLen - index + (currentPage - 1) * 100}.` + 28 | `${reply.author} ` + 29 | `${reply.op ? "[OP] " : ""}` + 30 | `${reply.like ? ` ${theme.like(`感谢:${reply.like} `)}` : ""}` + 31 | `(${reply.time})` 32 | ) 33 | ); 34 | console.log(theme.content(wrapText(reply.content))); 35 | if (reply.replyContent) { 36 | console.log(theme.reply(wrapText(reply.replyContent))); 37 | } 38 | console.log(""); 39 | }); 40 | 41 | if (currentPage === 1) { 42 | console.log(theme.default(`\n标题:${topic.title}\n`)); 43 | if (detail.content) { 44 | console.log(theme.content(wrapText(detail.content))); 45 | } 46 | console.log(theme.default("\n链接:" + topic.url)); 47 | } 48 | 49 | const choices = [{ name: theme.default("返回列表"), value: "back" }]; 50 | 51 | if (detail.next) { 52 | choices.unshift({ name: theme.default("下一页"), value: "next" }); 53 | } 54 | if (detail.prev) { 55 | choices.unshift({ name: theme.default("上一页"), value: "prev" }); 56 | } 57 | 58 | const { action } = await inquirer.prompt([ 59 | { 60 | type: "list", 61 | name: "action", 62 | message: theme.default("请选择操作:"), 63 | choices, 64 | }, 65 | ]); 66 | 67 | return action; 68 | } catch (error) { 69 | spinner.fail("获取失败!"); 70 | console.error(theme.error("错误信息:"), error.message); 71 | return "back"; 72 | } 73 | } 74 | 75 | async function main() { 76 | let topicsUrl = "/?tab=hot"; 77 | while (true) { 78 | const spinner = ora(theme.default("正在获取话题...")).start(); 79 | 80 | try { 81 | const { topics, tabs, currentTabTitle } = await getTopicsList(topicsUrl); 82 | 83 | spinner.succeed(theme.default("获取成功!")); 84 | 85 | const choices = topics.map((topic) => ({ 86 | name: theme.default(`${topic.title} (${topic.replies})`), 87 | value: topic, 88 | })); 89 | 90 | choices.push({ name: theme.default("🚪退出"), value: "exit" }); 91 | choices.push({ name: theme.default("🔧主题"), value: "theme" }); 92 | choices.push({ name: theme.default("🔍节点"), value: "node" }); 93 | 94 | const { selection } = await inquirer.prompt([ 95 | { 96 | type: "list", 97 | name: "selection", 98 | message: theme.default(`${currentTabTitle}:`), 99 | choices, 100 | }, 101 | ]); 102 | 103 | if (selection === "exit") { 104 | break; 105 | } else if (selection === "node") { 106 | const { selection: nodeSelection } = await inquirer.prompt([ 107 | { 108 | type: "list", 109 | name: "selection", 110 | message: theme.default("请选择节点:"), 111 | choices: tabs.map((tab) => ({ 112 | name: tab.title, 113 | value: tab, 114 | })), 115 | }, 116 | ]); 117 | 118 | if (nodeSelection === "back") { 119 | continue; 120 | } else { 121 | topicsUrl = nodeSelection.url; 122 | } 123 | } else if (selection === "theme") { 124 | const { selection: themeSelection } = await inquirer.prompt([ 125 | { 126 | type: "list", 127 | name: "selection", 128 | message: theme.default("请选择主题:"), 129 | choices: themeChoices, 130 | }, 131 | ]); 132 | theme = getTheme(themeSelection); 133 | } else { 134 | let currentPage = 1; 135 | while (true) { 136 | const action = await displayTopicDetail(selection, currentPage); 137 | if (action === "back") { 138 | break; 139 | } else if (action === "prev") { 140 | currentPage--; 141 | } else if (action === "next") { 142 | currentPage++; 143 | } 144 | } 145 | } 146 | } catch (error) { 147 | spinner.fail("获取失败!"); 148 | console.error(theme.error("错误信息:"), error.message); 149 | break; 150 | } 151 | } 152 | } 153 | 154 | main(); 155 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v2ex-shell", 3 | "version": "1.1.0", 4 | "description": "一个基于 Node.js 的 V2EX 命令行工具", 5 | "main": "index.js", 6 | "type": "module", 7 | "bin": { 8 | "v2ex-shell": "./index.js" 9 | }, 10 | "scripts": { 11 | "start": "node index.js", 12 | "test": "jest", 13 | "test:watch": "jest --watch", 14 | "test:coverage": "jest --coverage", 15 | "prepublishOnly": "npm test", 16 | "release:patch": "npm version patch && npm publish", 17 | "release:minor": "npm version minor && npm publish", 18 | "release:major": "npm version major && npm publish", 19 | "release:beta": "npm version prerelease --preid=beta && npm publish --tag beta", 20 | "release:alpha": "npm version prerelease --preid=alpha && npm publish --tag alpha" 21 | }, 22 | "keywords": [ 23 | "v2ex", 24 | "cli", 25 | "terminal", 26 | "command-line", 27 | "shell" 28 | ], 29 | "author": "Your Name", 30 | "license": "MIT", 31 | "dependencies": { 32 | "axios": "^1.6.7", 33 | "chalk": "^5.3.0", 34 | "cheerio": "^1.0.0-rc.12", 35 | "commander": "^12.0.0", 36 | "inquirer": "^9.2.15", 37 | "node-localstorage": "^3.0.5", 38 | "ora": "^7.0.1" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.24.0", 42 | "@babel/preset-env": "^7.24.0", 43 | "babel-jest": "^29.7.0", 44 | "jest": "^29.7.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /theme.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { LocalStorage } from "node-localstorage"; 3 | 4 | const localStorage = new LocalStorage('./storage'); 5 | 6 | const themes = { 7 | classic: { 8 | content: 'yellow', 9 | default: 'white', 10 | reply: 'blue', 11 | like: 'red', 12 | error: 'red', 13 | }, 14 | simple: { 15 | content: 'white', 16 | default: 'white', 17 | reply: 'white', 18 | like: 'white', 19 | error: 'white', 20 | }, 21 | dark: { 22 | content: 'gray', 23 | default: 'gray', 24 | reply: 'gray', 25 | like: 'gray', 26 | error: 'gray', 27 | }, 28 | }; 29 | 30 | export const themeChoices = [ 31 | { name: chalk.white("经典"), value: "classic" }, 32 | { name: chalk.white("简约"), value: "simple" }, 33 | { name: chalk.white("暗黑"), value: "dark" }, 34 | ]; 35 | 36 | export const getTheme = (themeName) => { 37 | if (themeName) { 38 | localStorage.setItem("theme", themeName); 39 | } 40 | const colors = themes[localStorage.getItem("theme")] || themes.classic; 41 | return { 42 | content: chalk[colors.content], 43 | default: chalk[colors.default], 44 | reply: chalk[colors.reply], 45 | like: chalk[colors.like], 46 | error: chalk[colors.error], 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | export function wrapText(text, len = 50) { 2 | let result = ""; 3 | let index = 0; 4 | text.split("").forEach((char) => { 5 | if (char === "\n") { 6 | index = 0; 7 | } 8 | if (index % len === 0 && index !== 0) { 9 | result += "\n"; 10 | } 11 | result += char; 12 | index++; 13 | }); 14 | return result; 15 | } 16 | 17 | export function getUserListInTopic(topic) { 18 | const userList = topic.match(/@[^\s]+/g); 19 | if (!userList) return []; 20 | return userList.map((user) => user.slice(1)); 21 | } 22 | 23 | export function refReplyContent(replies, lastReplys) { 24 | return replies.map((reply, index) => { 25 | let replyContent = ''; 26 | const userList = getUserListInTopic(reply.content); 27 | 28 | if (userList.length > 0) { 29 | userList.forEach(username => { 30 | const prevReply = lastReplys.slice(0, lastReplys.length - replies.length + index).reverse().find(r => r.author === username); 31 | if (prevReply) { 32 | replyContent += `\n引用 @${username} 的发言:\n${prevReply.content}\n`; 33 | } 34 | }); 35 | } 36 | 37 | return { 38 | ...reply, 39 | replyContent 40 | } 41 | }); 42 | } 43 | 44 | export function convertHTMLContentToText(content) { 45 | if (!content) { 46 | return ''; 47 | } 48 | return content 49 | .replace(//g, "\n") 50 | .replace(/]*>\s*]+src="([^"]+)"[^>]*>\s*<\/a>/g, '\n$1\n') 51 | .replace(/]+src="([^"]+)"[^>]*>/g, '\n$1\n') 52 | .replace(/<[^>]*>/g, "") 53 | .trim(); 54 | } 55 | --------------------------------------------------------------------------------