├── .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 | 
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 |
20 |
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 |
--------------------------------------------------------------------------------