feat: Add Xueqiu site with 6 new commands and a Twitter bookmarks command, updating documentation.

This commit is contained in:
jackwener committed 2026-03-15 16:03:45 +08:00
1 parent aef110a8f8
commit 790c94d644
11 files changed
+418 -6

No files matched your search

+5 -1
View File
@@ -55,7 +55,7 @@ opencli bilibili hot -v # 查看已有命令的 pipeline 每步数据流
在开始死磕复杂的抓包拦截之前,按照以下优先级进行尝试:
1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。
1. **后缀爆破法 (`.json`)**: 像 Reddit 这样复杂的网站,只要在其 URL 后加上 `.json`(例如 `/r/all.json`),就能在带 Cookie 的情况下直接利用 `fetch` 拿到极其干净的 REST 数据(Tier 2 Cookie 策略极速秒杀)。另外如功能完备的**雪球 (xueqiu)** 也可以走这种纯 API 的方式极简获取,成为你构建简单 YAML 的黄金标杆。
2. **全局状态查找法 (`__INITIAL_STATE__`)**: 许多服务端渲染 (SSR) 的网站(如小红书、Bilibili)会将首页或详情页的完整数据挂载到全局 window 对象上。与其去拦截网络请求,不如直接 `page.evaluate('() => window.__INITIAL_STATE__')` 获取整个数据树。
3. **框架探测与 Store Action 截断**: 如果站点使用 Vue + Pinia,可以使用 `tap` 步骤调用 action,让前端框架代替你完成复杂的鉴权签名封装。
4. **底层 XHR/Fetch 拦截**: 最后手段,当上述都不行时,使用 TypeScript 适配器进行无侵入式的请求抓取。
@@ -533,6 +533,10 @@ git commit -m "feat(mysite): add hot and search adapters"
git push
```
## 设计哲学: Zero-Dependency jq
> 💡 **架构理念升级**: OpenCLI 的原生机制本质上内建了一个 **Zero-Dependency jq 数据处理流**。使用时不需要依赖系统命令级别的 `jq` 包,而是将所有的解析拍平动作放在 `evaluate` 块内的原生 JavaScript 里,再由外层 YAML 通过 `select`、`map` 等命令提取。这将彻底消灭跨操作系统下产生的第三方二进制库依赖。
---
## 常见陷阱
+3 -2
View File
@@ -7,7 +7,7 @@
[![npm](https://img.shields.io/npm/v/@jackwener/opencli)](https://www.npmjs.com/package/@jackwener/opencli)
A CLI tool that turns **any website** into a command-line interface. **28+ commands** across **16 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
A CLI tool that turns **any website** into a command-line interface. **35+ commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery.
## ✨ Highlights
@@ -85,7 +85,8 @@ Public API commands (`hackernews`, `github search`, `v2ex`) need no browser at a
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` | 🔐 Browser |
| **zhihu** | `hot` `search` `question` | 🔐 Browser |
| **xiaohongshu** | `search` `notifications` `feed` | 🔐 Browser |
| **twitter** | `trending` | 🔐 Browser |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
| **twitter** | `trending` `bookmarks` | 🔐 Browser |
| **reddit** | `hot` | 🔐 Browser |
| **weibo** | `hot` | 🔐 Browser |
| **boss** | `search` | 🔐 Browser |
+3 -2
View File
@@ -11,7 +11,7 @@ OpenCLI 通过 Chrome 浏览器 + [Playwright MCP Bridge](https://github.com/nic
## ✨ 亮点
- 🌐 **28+ 命令,16 个站点** — B站、知乎、小红书、Twitter、Reddit、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
- 🌐 **35+ 命令,17 个站点** — B站、知乎、小红书、Twitter、Reddit、雪球(xueqiu)、GitHub、V2EX、Hacker News、BBC、微博、BOSS直聘、Yahoo Finance、路透社、什么值得买、携程、YouTube
- 🔐 **零风控** — 复用 Chrome 登录态,无需存储任何凭证
- 🤖 **AI 原生** — `explore` 自动发现 API,`synthesize` 生成适配器,`cascade` 探测认证策略
- 🚀 **动态加载引擎** — 只需将 `.ts` 或 `.yaml` 适配器放入 `clis/` 文件夹即可自动注册生效
@@ -86,7 +86,8 @@ npm install -g @jackwener/opencli@latest
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `user-videos` | 🔐 浏览器 |
| **zhihu** | `hot` `search` `question` | 🔐 浏览器 |
| **xiaohongshu** | `search` `notifications` `feed` | 🔐 浏览器 |
| **twitter** | `trending` | 🔐 浏览器 |
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 浏览器 |
| **twitter** | `trending` `bookmarks` | 🔐 浏览器 |
| **reddit** | `hot` | 🔐 浏览器 |
| **weibo** | `hot` | 🔐 浏览器 |
| **boss** | `search` | 🔐 浏览器 |
+8 -1
View File
@@ -3,7 +3,7 @@ name: opencli
description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
version: 0.1.0
author: jackwener
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, AI, agent]
tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, AI, agent]
---
# OpenCLI
@@ -60,12 +60,19 @@ opencli xiaohongshu search --keyword "美食" # 搜索笔记
opencli xiaohongshu notifications # 通知(mentions/likes/connections)
opencli xiaohongshu feed --limit 10 # 推荐 Feed
# 雪球 Xueqiu (browser)
opencli xueqiu hot-stock --limit 10 # 雪球热门股票榜
opencli xueqiu stock --symbol SH600519 # 查看股票实时行情
opencli xueqiu watchlist # 获取自选股/持仓列表
opencli xueqiu feed # 我的关注 timeline
# GitHub (trending=browser, search=public)
opencli github trending --limit 10 # GitHub Trending
opencli github search --keyword "cli" # 搜索仓库
# Twitter/X (browser)
opencli twitter trending --limit 10 # 热门话题
opencli twitter bookmarks --limit 20 # 获取收藏的书签推文
# Reddit (browser)
opencli reddit hot --limit 10 # 热门帖子
+85
View File
@@ -0,0 +1,85 @@
site: twitter
name: bookmarks
description: 获取 Twitter 书签列表
domain: x.com
browser: true
args:
limit:
type: int
default: 20
description: Number of bookmarks to return (default 20)
pipeline:
- navigate: https://x.com/i/bookmarks
- wait: 2
- evaluate: |
(async () => {
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
if (!ct0) throw new Error('No ct0 cookie. Hint: Not logged into x.com.');
const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
const _h = {'Authorization':'Bearer '+bearer, 'X-Csrf-Token':ct0, 'X-Twitter-Auth-Type':'OAuth2Session', 'X-Twitter-Active-User':'yes'};
const count = Math.min(${{ args.limit }}, 100);
const variables = JSON.stringify({count, includePromotedContent: false});
const features = JSON.stringify({
rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false, rweb_tipjar_consumption_enabled: false,
verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
content_disclosure_indicator_enabled: true, content_disclosure_ai_generated_indicator_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: false,
responsive_web_enhance_cards_enabled: false
});
const url = '/i/api/graphql/Fy0QMy4q_aZCpkO0PnyLYw/Bookmarks?variables=' + encodeURIComponent(variables) + '&features=' + encodeURIComponent(features);
const resp = await fetch(url, {headers: _h, credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + '. Hint: queryId may have changed.');
const d = await resp.json();
const instructions = d.data?.bookmark_timeline_v2?.timeline?.instructions || d.data?.bookmark_timeline?.timeline?.instructions || [];
let tweets = [], seen = new Set();
for (const inst of instructions) {
for (const entry of (inst.entries || [])) {
const r = entry.content?.itemContent?.tweet_results?.result;
if (!r) continue;
const tw = r.tweet || r;
const l = tw.legacy || {};
if (!tw.rest_id || seen.has(tw.rest_id)) continue;
seen.add(tw.rest_id);
const u = tw.core?.user_results?.result;
const nt = tw.note_tweet?.note_tweet_results?.result?.text;
const screenName = u?.legacy?.screen_name || u?.core?.screen_name;
tweets.push({
id: tw.rest_id,
author: screenName,
name: u?.legacy?.name || u?.core?.name,
url: 'https://x.com/' + (screenName || '_') + '/status/' + tw.rest_id,
text: nt || l.full_text || '',
likes: l.favorite_count,
retweets: l.retweet_count,
created_at: l.created_at
});
}
}
return tweets;
})()
- map:
author: ${{ item.author }}
text: ${{ item.text }}
likes: ${{ item.likes }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [author, text, likes, url]
+53
View File
@@ -0,0 +1,53 @@
site: xueqiu
name: feed
description: 获取雪球首页时间线(关注用户的动态)
domain: xueqiu.com
browser: true
args:
page:
type: int
default: 1
description: 页码,默认 1
limit:
type: int
default: 20
description: 每页数量,默认 20
pipeline:
- navigate: https://xueqiu.com
- evaluate: |
(async () => {
const page = ${{ args.page }};
const count = ${{ args.limit }};
const resp = await fetch(`https://xueqiu.com/v4/statuses/home_timeline.json?page=${page}&count=${count}`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
const list = d.home_timeline || d.list || [];
return list.map(item => {
const user = item.user || {};
return {
id: item.id,
text: strip(item.description).substring(0, 200),
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
author: user.screen_name,
likes: item.fav_count,
retweets: item.retweet_count,
replies: item.reply_count,
created_at: item.created_at ? new Date(item.created_at).toISOString() : null
};
});
})()
- map:
author: ${{ item.author }}
text: ${{ item.text }}
likes: ${{ item.likes }}
replies: ${{ item.replies }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [author, text, likes, replies, url]
+49
View File
@@ -0,0 +1,49 @@
site: xueqiu
name: hot-stock
description: 获取雪球热门股票榜
domain: xueqiu.com
browser: true
args:
limit:
type: int
default: 20
description: 返回数量,默认 20,最大 50
type:
type: str
default: "10"
description: 榜单类型 10=人气榜(默认) 12=关注榜
pipeline:
- navigate: https://xueqiu.com
- evaluate: |
(async () => {
const count = ${{ args.limit }};
const type = ${{ args.type | json }};
const resp = await fetch(`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${count}&type=${type}`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.items) throw new Error('获取失败');
return d.data.items.map((s, i) => ({
rank: i + 1,
symbol: s.symbol,
name: s.name,
price: s.current,
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
heat: s.value,
rank_change: s.rank_change,
url: 'https://xueqiu.com/S/' + s.symbol
}));
})()
- map:
rank: ${{ item.rank }}
symbol: ${{ item.symbol }}
name: ${{ item.name }}
price: ${{ item.price }}
changePercent: ${{ item.changePercent }}
heat: ${{ item.heat }}
- limit: ${{ args.limit }}
columns: [rank, symbol, name, price, changePercent, heat]
+46
View File
@@ -0,0 +1,46 @@
site: xueqiu
name: hot
description: 获取雪球热门动态
domain: xueqiu.com
browser: true
args:
limit:
type: int
default: 20
description: 返回数量,默认 20,最大 50
pipeline:
- navigate: https://xueqiu.com
- evaluate: |
(async () => {
const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
const list = d.list || [];
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
return list.map((item, i) => {
const user = item.user || {};
return {
rank: i + 1,
text: strip(item.description).substring(0, 200),
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
author: user.screen_name,
likes: item.fav_count,
retweets: item.retweet_count,
replies: item.reply_count
};
});
})()
- map:
rank: ${{ item.rank }}
author: ${{ item.author }}
text: ${{ item.text }}
likes: ${{ item.likes }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [rank, author, text, likes, url]
+53
View File
@@ -0,0 +1,53 @@
site: xueqiu
name: search
description: 搜索雪球股票(代码或名称)
domain: xueqiu.com
browser: true
args:
query:
type: str
description: 搜索关键词,如 茅台、AAPL、腾讯
limit:
type: int
default: 10
description: 返回数量,默认 10
pipeline:
- navigate: https://xueqiu.com
- evaluate: |
(async () => {
const query = ${{ args.query | json }};
const count = ${{ args.limit }};
if (!query) throw new Error('Missing argument: query');
const resp = await fetch(`https://xueqiu.com/stock/search.json?code=${encodeURIComponent(query)}&size=${count}`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
return (d.stocks || []).map(s => {
let symbol = '';
if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {
symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;
} else {
symbol = s.code;
}
return {
symbol: symbol,
name: s.name,
exchange: s.exchange,
price: s.current,
changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,
url: 'https://xueqiu.com/S/' + symbol
};
});
})()
- map:
symbol: ${{ item.symbol }}
name: ${{ item.name }}
exchange: ${{ item.exchange }}
price: ${{ item.price }}
changePercent: ${{ item.changePercent }}
- limit: ${{ args.limit }}
columns: [symbol, name, exchange, price, changePercent]
+67
View File
@@ -0,0 +1,67 @@
site: xueqiu
name: stock
description: 获取雪球股票实时行情
domain: xueqiu.com
browser: true
args:
symbol:
type: str
description: 股票代码,如 SH600519、SZ000858、AAPL、00700
pipeline:
- navigate: https://xueqiu.com
- evaluate: |
(async () => {
const symbol = (${{ args.symbol | json }} || '').toUpperCase();
if (!symbol) throw new Error('Missing argument: symbol');
const resp = await fetch(`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol);
function fmtAmount(v) {
if (v == null) return null;
if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';
if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';
return v.toString();
}
const item = d.data.items[0];
const q = item.quote || {};
const m = item.market || {};
return [{
name: q.name,
symbol: q.symbol,
exchange: q.exchange,
currency: q.currency,
price: q.current,
change: q.chg,
changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,
open: q.open,
high: q.high,
low: q.low,
prevClose: q.last_close,
amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,
volume: q.volume,
amount: fmtAmount(q.amount),
turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,
marketCap: fmtAmount(q.market_capital),
floatMarketCap: fmtAmount(q.float_market_capital),
ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,
market_status: m.status || null,
time: q.timestamp ? new Date(q.timestamp).toISOString() : null,
url: 'https://xueqiu.com/S/' + q.symbol
}];
})()
- map:
name: ${{ item.name }}
symbol: ${{ item.symbol }}
price: ${{ item.price }}
changePercent: ${{ item.changePercent }}
marketCap: ${{ item.marketCap }}
columns: [name, symbol, price, changePercent, marketCap]
+46
View File
@@ -0,0 +1,46 @@
site: xueqiu
name: watchlist
description: 获取雪球自选股列表
domain: xueqiu.com
browser: true
args:
category:
type: str # using str to prevent parsing issues like 01
default: "1"
description: "分类:1=自选(默认) 2=持仓 3=关注"
limit:
type: int
default: 100
description: 默认 100
pipeline:
- navigate: https://xueqiu.com
- evaluate: |
(async () => {
const category = parseInt(${{ args.category | json }}) || 1;
const resp = await fetch(`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=${category}&pid=-1`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
return d.data.stocks.map(s => ({
symbol: s.symbol,
name: s.name,
price: s.current,
change: s.chg,
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
volume: s.volume,
url: 'https://xueqiu.com/S/' + s.symbol
}));
})()
- map:
symbol: ${{ item.symbol }}
name: ${{ item.name }}
price: ${{ item.price }}
changePercent: ${{ item.changePercent }}
- limit: ${{ args.limit }}
columns: [symbol, name, price, changePercent]