@jackwener/opencli 1.7.8 → 1.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -14
- package/README.zh-CN.md +30 -10
- package/cli-manifest.json +646 -30
- package/clis/36kr/news.js +1 -1
- package/clis/apple-podcasts/commands.test.js +4 -4
- package/clis/apple-podcasts/episodes.js +1 -1
- package/clis/apple-podcasts/search.js +1 -1
- package/clis/apple-podcasts/top.js +1 -1
- package/clis/arxiv/paper.js +1 -1
- package/clis/arxiv/search.js +1 -1
- package/clis/band/mentions.js +3 -3
- package/clis/bbc/news.js +1 -1
- package/clis/bilibili/subtitle.js +2 -2
- package/clis/bloomberg/businessweek.js +1 -1
- package/clis/bloomberg/economics.js +1 -1
- package/clis/bloomberg/industries.js +1 -1
- package/clis/bloomberg/main.js +1 -1
- package/clis/bloomberg/markets.js +1 -1
- package/clis/bloomberg/opinions.js +1 -1
- package/clis/bloomberg/politics.js +1 -1
- package/clis/bloomberg/tech.js +1 -1
- package/clis/boss/search.js +49 -8
- package/clis/boss/search.test.js +78 -0
- package/clis/boss/send.js +3 -3
- package/clis/chatgpt/image.js +37 -8
- package/clis/chatgpt/image.test.js +92 -0
- package/clis/chatgpt/utils.js +39 -6
- package/clis/chatgpt/utils.test.js +63 -0
- package/clis/chatgpt-app/ask.js +1 -1
- package/clis/chatgpt-app/ax.js +4 -2
- package/clis/chatgpt-app/ax.test.js +12 -0
- package/clis/chatgpt-app/model.js +1 -1
- package/clis/chatgpt-app/new.js +1 -1
- package/clis/chatgpt-app/read.js +1 -1
- package/clis/chatgpt-app/send.js +1 -1
- package/clis/chatgpt-app/status.js +1 -1
- package/clis/chatwise/ask.js +2 -2
- package/clis/chatwise/model.js +2 -2
- package/clis/chatwise/send.js +2 -2
- package/clis/claude/ask.js +128 -0
- package/clis/claude/ask.test.js +338 -0
- package/clis/claude/commands.test.js +118 -0
- package/clis/claude/detail.js +29 -0
- package/clis/claude/history.js +31 -0
- package/clis/claude/new.js +21 -0
- package/clis/claude/read.js +24 -0
- package/clis/claude/send.js +41 -0
- package/clis/claude/status.js +24 -0
- package/clis/claude/utils.js +440 -0
- package/clis/claude/utils.test.js +148 -0
- package/clis/codex/ask.js +2 -2
- package/clis/codex/send.js +2 -2
- package/clis/ctrip/search.js +1 -1
- package/clis/ctrip/search.test.js +4 -4
- package/clis/cursor/ask.js +2 -2
- package/clis/cursor/composer.js +2 -2
- package/clis/cursor/send.js +2 -2
- package/clis/deepseek/ask.js +17 -4
- package/clis/deepseek/ask.test.js +46 -0
- package/clis/deepseek/utils.js +55 -16
- package/clis/deepseek/utils.test.js +124 -5
- package/clis/doubao/utils.js +53 -11
- package/clis/doubao/utils.test.js +22 -2
- package/clis/eastmoney/announcement.js +1 -1
- package/clis/eastmoney/convertible.js +1 -1
- package/clis/eastmoney/etf.js +1 -1
- package/clis/eastmoney/holders.js +1 -1
- package/clis/eastmoney/index-board.js +1 -1
- package/clis/eastmoney/kline.js +1 -1
- package/clis/eastmoney/kuaixun.js +1 -1
- package/clis/eastmoney/longhu.js +1 -1
- package/clis/eastmoney/money-flow.js +1 -1
- package/clis/eastmoney/northbound.js +1 -1
- package/clis/eastmoney/quote.js +1 -1
- package/clis/eastmoney/rank.js +1 -1
- package/clis/eastmoney/sectors.js +1 -1
- package/clis/facebook/marketplace-inbox.js +83 -0
- package/clis/facebook/marketplace-listings.js +83 -0
- package/clis/facebook/marketplace.test.js +91 -0
- package/clis/google/news.js +1 -1
- package/clis/google/suggest.js +1 -1
- package/clis/google/trends.js +1 -1
- package/clis/google-scholar/cite.js +74 -0
- package/clis/google-scholar/cite.test.js +47 -0
- package/clis/google-scholar/profile.js +92 -0
- package/clis/google-scholar/profile.test.js +49 -0
- package/clis/google-scholar/search.js +1 -1
- package/clis/google-scholar/search.test.js +15 -0
- package/clis/hf/top.js +1 -1
- package/clis/instagram/collection-create.js +57 -0
- package/clis/instagram/saved.js +21 -7
- package/clis/jd/item.js +679 -47
- package/clis/jd/item.test.js +318 -7
- package/clis/jd/item.test.ts +517 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/tags.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/lesswrong/user-posts.js +1 -1
- package/clis/lesswrong/user.js +1 -1
- package/clis/paperreview/commands.test.js +6 -6
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/producthunt/posts.js +1 -1
- package/clis/producthunt/today.js +1 -1
- package/clis/sinablog/search.js +1 -1
- package/clis/sinafinance/news.js +1 -1
- package/clis/sinafinance/stock.js +1 -1
- package/clis/sinafinance/stock.test.js +2 -2
- package/clis/spotify/spotify.js +6 -6
- package/clis/substack/search.js +1 -1
- package/clis/toutiao/articles.js +5 -6
- package/clis/toutiao/articles.test.js +22 -15
- package/clis/twitter/followers.js +2 -2
- package/clis/twitter/following.js +224 -73
- package/clis/twitter/following.test.js +277 -0
- package/clis/twitter/post.js +184 -47
- package/clis/twitter/post.test.js +114 -34
- package/clis/uiverse/_shared.js +63 -4
- package/clis/uiverse/_shared.test.js +7 -0
- package/clis/uiverse/code.js +1 -0
- package/clis/uiverse/navigation.test.js +12 -0
- package/clis/uiverse/preview.js +1 -0
- package/clis/web/read.js +319 -81
- package/clis/web/read.test.js +221 -5
- package/clis/weibo/favorites.js +169 -0
- package/clis/weibo/favorites.test.js +114 -0
- package/clis/weibo/publish.js +282 -0
- package/clis/weibo/publish.test.js +183 -0
- package/clis/weread/ranking.js +1 -1
- package/clis/weread/search-regression.test.js +8 -8
- package/clis/weread/search.js +1 -1
- package/clis/wikipedia/random.js +1 -1
- package/clis/wikipedia/search.js +1 -1
- package/clis/wikipedia/summary.js +1 -1
- package/clis/wikipedia/trending.js +1 -1
- package/clis/xianyu/chat.js +3 -3
- package/clis/xianyu/item.js +2 -2
- package/clis/xianyu/item.test.js +3 -3
- package/clis/xiaohongshu/search.js +17 -2
- package/clis/xiaohongshu/search.test.js +37 -1
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/download.test.js +3 -3
- package/clis/xiaoyuzhou/episode.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
- package/clis/xiaoyuzhou/podcast.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/xiaoyuzhou/transcript.test.js +5 -5
- package/clis/yollomi/models.js +1 -1
- package/clis/youtube/channel.js +24 -1
- package/clis/youtube/channel.test.js +59 -0
- package/clis/zhihu/answer.js +21 -162
- package/clis/zhihu/answer.test.js +26 -53
- package/clis/zhihu/collection.js +197 -0
- package/clis/zhihu/collection.test.js +290 -0
- package/clis/zhihu/collections.js +127 -0
- package/clis/zhihu/collections.test.js +182 -0
- package/clis/zhihu/comment.js +24 -305
- package/clis/zhihu/comment.test.js +31 -35
- package/clis/zhihu/favorite.js +44 -182
- package/clis/zhihu/favorite.test.js +30 -167
- package/clis/zhihu/follow.js +25 -56
- package/clis/zhihu/follow.test.js +20 -23
- package/clis/zhihu/like.js +22 -67
- package/clis/zhihu/like.test.js +19 -42
- package/clis/zhihu/search.js +3 -2
- package/clis/zhihu/write-shared.js +8 -1
- package/clis/zhihu/write-shared.test.js +1 -0
- package/clis/zlibrary/commands.test.js +75 -0
- package/clis/zlibrary/info.js +47 -0
- package/clis/zlibrary/search.js +46 -0
- package/clis/zlibrary/utils.js +136 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/browser/base-page.d.ts +3 -1
- package/dist/src/browser/base-page.js +76 -1
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +74 -0
- package/dist/src/browser/bridge.d.ts +1 -2
- package/dist/src/browser/bridge.js +40 -41
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/daemon-client.d.ts +38 -4
- package/dist/src/browser/daemon-client.js +24 -7
- package/dist/src/browser/daemon-client.test.js +49 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
- package/dist/src/browser/daemon-lifecycle.js +67 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/errors.js +3 -0
- package/dist/src/browser/errors.test.js +3 -0
- package/dist/src/browser/network-cache.d.ts +1 -0
- package/dist/src/browser/page.d.ts +3 -1
- package/dist/src/browser/page.js +10 -2
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +85 -0
- package/dist/src/build-manifest.d.ts +2 -0
- package/dist/src/build-manifest.js +13 -3
- package/dist/src/build-manifest.test.js +20 -2
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +477 -35
- package/dist/src/cli.test.js +303 -2
- package/dist/src/commanderAdapter.js +17 -9
- package/dist/src/commanderAdapter.test.js +67 -2
- package/dist/src/commands/daemon.d.ts +2 -0
- package/dist/src/commands/daemon.js +42 -1
- package/dist/src/commands/daemon.test.js +103 -2
- package/dist/src/completion-shared.js +1 -2
- package/dist/src/completion.test.js +3 -2
- package/dist/src/daemon.js +125 -41
- package/dist/src/doctor.d.ts +5 -6
- package/dist/src/doctor.js +77 -19
- package/dist/src/doctor.test.js +117 -0
- package/dist/src/engine.test.js +6 -5
- package/dist/src/errors.d.ts +14 -8
- package/dist/src/errors.js +36 -30
- package/dist/src/errors.test.js +5 -5
- package/dist/src/execution.d.ts +4 -0
- package/dist/src/execution.js +173 -25
- package/dist/src/execution.test.js +171 -1
- package/dist/src/main.js +10 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/pipeline/steps/fetch.js +1 -21
- package/dist/src/pipeline/steps/fetch.test.js +6 -12
- package/dist/src/plugin-scaffold.js +1 -1
- package/dist/src/plugin-scaffold.test.js +1 -1
- package/dist/src/registry.d.ts +40 -9
- package/dist/src/registry.js +3 -1
- package/dist/src/runtime-detect.d.ts +10 -0
- package/dist/src/runtime-detect.js +19 -0
- package/dist/src/runtime-detect.test.js +12 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +22 -0
- package/dist/src/update-check.d.ts +31 -1
- package/dist/src/update-check.js +62 -16
- package/dist/src/update-check.test.js +86 -1
- package/package.json +1 -1
- package/dist/src/diagnostic.d.ts +0 -63
- package/dist/src/diagnostic.js +0 -292
- package/dist/src/diagnostic.test.js +0 -302
- /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
|
@@ -47,6 +47,11 @@ export function noteIdToDate(url) {
|
|
|
47
47
|
// Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
|
|
48
48
|
return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
|
|
49
49
|
}
|
|
50
|
+
export function stripXhsAuthorDateSuffix(value) {
|
|
51
|
+
const text = (value || '').replace(/\s+/g, ' ').trim();
|
|
52
|
+
const stripped = text.replace(/\s*(?:\d{1,2}天前|\d+小时前|\d+分钟前|\d+秒前|刚刚|昨天|前天|\d+周前|\d+个月前|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2})$/u, '').trim();
|
|
53
|
+
return stripped || text;
|
|
54
|
+
}
|
|
50
55
|
cli({
|
|
51
56
|
site: 'xiaohongshu',
|
|
52
57
|
name: 'search',
|
|
@@ -81,6 +86,7 @@ cli({
|
|
|
81
86
|
};
|
|
82
87
|
|
|
83
88
|
const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
89
|
+
const stripXhsAuthorDateSuffix = ${stripXhsAuthorDateSuffix.toString()};
|
|
84
90
|
|
|
85
91
|
const results = [];
|
|
86
92
|
const seen = new Set();
|
|
@@ -90,7 +96,13 @@ cli({
|
|
|
90
96
|
if (el.classList.contains('query-note-item')) return;
|
|
91
97
|
|
|
92
98
|
const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
|
|
93
|
-
const nameEl = el.querySelector('a.author .name, .
|
|
99
|
+
const nameEl = el.querySelector('a.author .name, .author-name, .nick-name, .name');
|
|
100
|
+
const authorWrapEl = el.querySelector('a.author');
|
|
101
|
+
let author = cleanText(nameEl?.textContent || '');
|
|
102
|
+
if (!author && authorWrapEl) {
|
|
103
|
+
const nameChild = authorWrapEl.querySelector('.name');
|
|
104
|
+
author = nameChild ? cleanText(nameChild.textContent || '') : stripXhsAuthorDateSuffix(authorWrapEl.textContent || '');
|
|
105
|
+
}
|
|
94
106
|
const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
|
|
95
107
|
// Prefer search_result link (preserves xsec_token) over generic /explore/ link
|
|
96
108
|
const detailLinkEl =
|
|
@@ -109,7 +121,7 @@ cli({
|
|
|
109
121
|
|
|
110
122
|
results.push({
|
|
111
123
|
title: cleanText(titleEl?.textContent || ''),
|
|
112
|
-
author
|
|
124
|
+
author,
|
|
113
125
|
likes: cleanText(likesEl?.textContent || '0'),
|
|
114
126
|
url,
|
|
115
127
|
author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
|
|
@@ -130,3 +142,6 @@ cli({
|
|
|
130
142
|
}));
|
|
131
143
|
},
|
|
132
144
|
});
|
|
145
|
+
export const __test__ = {
|
|
146
|
+
stripXhsAuthorDateSuffix,
|
|
147
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import {
|
|
3
|
+
import { JSDOM } from 'jsdom';
|
|
4
|
+
import { __test__, noteIdToDate } from './search.js';
|
|
4
5
|
function createPageMock(evaluateResults) {
|
|
5
6
|
const evaluate = vi.fn();
|
|
6
7
|
for (const result of evaluateResults) {
|
|
@@ -127,6 +128,41 @@ describe('xiaohongshu search', () => {
|
|
|
127
128
|
// Two evaluate calls: wait + extraction
|
|
128
129
|
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
129
130
|
});
|
|
131
|
+
it('separates fallback author text from appended relative date', async () => {
|
|
132
|
+
const cmd = getRegistry().get('xiaohongshu/search');
|
|
133
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
134
|
+
const dom = new JSDOM(`
|
|
135
|
+
<section class="note-item">
|
|
136
|
+
<a class="cover mask" href="/search_result/68e90be80000000004022e66?xsec_token=test-token"></a>
|
|
137
|
+
<div class="title">数字作者测试</div>
|
|
138
|
+
<a class="author" href="/user/profile/author123">
|
|
139
|
+
<span>数字3天前端</span><span>3天前</span>
|
|
140
|
+
</a>
|
|
141
|
+
<span class="count">8</span>
|
|
142
|
+
</section>
|
|
143
|
+
`, { url: 'https://www.xiaohongshu.com/search_result?keyword=test' });
|
|
144
|
+
const page = createPageMock([]);
|
|
145
|
+
page.evaluate.mockImplementationOnce(async () => 'content');
|
|
146
|
+
page.evaluate.mockImplementationOnce(async (script) => Function('document', `return (${script})`)(dom.window.document));
|
|
147
|
+
|
|
148
|
+
const result = await cmd.func(page, { query: '测试', limit: 1 });
|
|
149
|
+
|
|
150
|
+
expect(result[0]).toMatchObject({
|
|
151
|
+
title: '数字作者测试',
|
|
152
|
+
author: '数字3天前端',
|
|
153
|
+
likes: '8',
|
|
154
|
+
author_url: 'https://www.xiaohongshu.com/user/profile/author123',
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('stripXhsAuthorDateSuffix', () => {
|
|
159
|
+
it('only strips trailing date suffixes and preserves date-like author text', () => {
|
|
160
|
+
expect(__test__.stripXhsAuthorDateSuffix('作者名 3天前')).toBe('作者名');
|
|
161
|
+
expect(__test__.stripXhsAuthorDateSuffix('作者名2026-04-01')).toBe('作者名');
|
|
162
|
+
expect(__test__.stripXhsAuthorDateSuffix('3天前端工程师')).toBe('3天前端工程师');
|
|
163
|
+
expect(__test__.stripXhsAuthorDateSuffix('刚刚好')).toBe('刚刚好');
|
|
164
|
+
expect(__test__.stripXhsAuthorDateSuffix('刚刚')).toBe('刚刚');
|
|
165
|
+
});
|
|
130
166
|
});
|
|
131
167
|
describe('noteIdToDate (ObjectID timestamp parsing)', () => {
|
|
132
168
|
it('parses a known note ID to the correct China-timezone date', () => {
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
{ name: 'output', default: './xiaoyuzhou-downloads', help: 'Output directory' },
|
|
19
19
|
],
|
|
20
20
|
columns: ['title', 'podcast', 'status', 'size', 'file'],
|
|
21
|
-
func: async (
|
|
21
|
+
func: async (args) => {
|
|
22
22
|
const credentials = loadXiaoyuzhouCredentials();
|
|
23
23
|
const response = await requestXiaoyuzhouJson('/v1/episode/get', {
|
|
24
24
|
query: { eid: args.id },
|
|
@@ -68,7 +68,7 @@ describe('xiaoyuzhou download', () => {
|
|
|
68
68
|
});
|
|
69
69
|
mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
|
|
70
70
|
|
|
71
|
-
const result = await cmd.func(
|
|
71
|
+
const result = await cmd.func({
|
|
72
72
|
id: 'ep123',
|
|
73
73
|
output: '/tmp/xiaoyuzhou-test',
|
|
74
74
|
});
|
|
@@ -106,7 +106,7 @@ describe('xiaoyuzhou download', () => {
|
|
|
106
106
|
});
|
|
107
107
|
mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
|
|
108
108
|
|
|
109
|
-
const result = await cmd.func(
|
|
109
|
+
const result = await cmd.func({
|
|
110
110
|
id: 'ep456',
|
|
111
111
|
output: '/tmp/xiaoyuzhou-test',
|
|
112
112
|
});
|
|
@@ -125,7 +125,7 @@ describe('xiaoyuzhou download', () => {
|
|
|
125
125
|
},
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
await expect(cmd.func(
|
|
128
|
+
await expect(cmd.func({ id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
|
|
129
129
|
code: 'PARSE_ERROR',
|
|
130
130
|
message: 'Audio URL not found in episode payload',
|
|
131
131
|
hint: 'Episode payload does not expose media.source.url',
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
browser: false,
|
|
12
12
|
args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
|
|
13
13
|
columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (args) => {
|
|
15
15
|
const credentials = loadXiaoyuzhouCredentials();
|
|
16
16
|
const response = await requestXiaoyuzhouJson('/v1/episode/get', {
|
|
17
17
|
query: { eid: args.id },
|
|
@@ -14,7 +14,7 @@ cli({
|
|
|
14
14
|
{ name: 'limit', type: 'int', default: 20, help: 'Max episodes to show' },
|
|
15
15
|
],
|
|
16
16
|
columns: ['eid', 'title', 'duration', 'plays', 'date'],
|
|
17
|
-
func: async (
|
|
17
|
+
func: async (args) => {
|
|
18
18
|
const requestedLimit = Number(args.limit);
|
|
19
19
|
if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
|
|
20
20
|
throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
|
|
@@ -44,7 +44,7 @@ describe('xiaoyuzhou podcast-episodes', () => {
|
|
|
44
44
|
],
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
const result = await cmd.func(
|
|
47
|
+
const result = await cmd.func({
|
|
48
48
|
id: 'podcast-1',
|
|
49
49
|
limit: 3,
|
|
50
50
|
});
|
|
@@ -66,7 +66,7 @@ describe('xiaoyuzhou podcast-episodes', () => {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
it('rejects non-positive limits before hitting the API', async () => {
|
|
69
|
-
await expect(cmd.func(
|
|
69
|
+
await expect(cmd.func({
|
|
70
70
|
id: 'podcast-1',
|
|
71
71
|
limit: 0,
|
|
72
72
|
})).rejects.toMatchObject({
|
|
@@ -11,7 +11,7 @@ cli({
|
|
|
11
11
|
browser: false,
|
|
12
12
|
args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
|
|
13
13
|
columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
|
|
14
|
-
func: async (
|
|
14
|
+
func: async (args) => {
|
|
15
15
|
const credentials = loadXiaoyuzhouCredentials();
|
|
16
16
|
const response = await requestXiaoyuzhouJson('/v1/podcast/get', {
|
|
17
17
|
query: { pid: args.id },
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
{ name: 'text', type: 'boolean', default: true, help: 'Save extracted transcript text file' },
|
|
19
19
|
],
|
|
20
20
|
columns: ['title', 'podcast', 'status', 'segments', 'json_file', 'text_file'],
|
|
21
|
-
func: async (
|
|
21
|
+
func: async (kwargs) => {
|
|
22
22
|
if (kwargs.json === false && kwargs.text === false) {
|
|
23
23
|
throw new ArgumentError('At least one of --json or --text must be enabled', 'Example: opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --text true');
|
|
24
24
|
}
|
|
@@ -67,7 +67,7 @@ describe('xiaoyuzhou transcript', () => {
|
|
|
67
67
|
mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
|
|
68
68
|
segments: [{ text: 'hello' }, { text: 'world' }],
|
|
69
69
|
}));
|
|
70
|
-
const result = await cmd.func(
|
|
70
|
+
const result = await cmd.func({
|
|
71
71
|
id: 'ep123',
|
|
72
72
|
output: '/tmp/xiaoyuzhou-transcripts',
|
|
73
73
|
json: true,
|
|
@@ -112,7 +112,7 @@ describe('xiaoyuzhou transcript', () => {
|
|
|
112
112
|
},
|
|
113
113
|
});
|
|
114
114
|
mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({ text: 'hello' }));
|
|
115
|
-
await cmd.func(
|
|
115
|
+
await cmd.func({
|
|
116
116
|
id: 'ep456',
|
|
117
117
|
output: '/tmp/xiaoyuzhou-transcripts',
|
|
118
118
|
json: false,
|
|
@@ -137,7 +137,7 @@ describe('xiaoyuzhou transcript', () => {
|
|
|
137
137
|
credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
|
|
138
138
|
data: {},
|
|
139
139
|
});
|
|
140
|
-
await expect(cmd.func(
|
|
140
|
+
await expect(cmd.func({
|
|
141
141
|
id: 'ep123',
|
|
142
142
|
output: '/tmp/xiaoyuzhou-transcripts',
|
|
143
143
|
json: true,
|
|
@@ -168,7 +168,7 @@ describe('xiaoyuzhou transcript', () => {
|
|
|
168
168
|
mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
|
|
169
169
|
segments: [{ startAt: 0, endAt: 1 }],
|
|
170
170
|
}));
|
|
171
|
-
await expect(cmd.func(
|
|
171
|
+
await expect(cmd.func({
|
|
172
172
|
id: 'ep123',
|
|
173
173
|
output: '/tmp/xiaoyuzhou-transcripts',
|
|
174
174
|
json: true,
|
|
@@ -181,7 +181,7 @@ describe('xiaoyuzhou transcript', () => {
|
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
it('rejects disabling both json and text outputs', async () => {
|
|
184
|
-
await expect(cmd.func(
|
|
184
|
+
await expect(cmd.func({
|
|
185
185
|
id: 'ep123',
|
|
186
186
|
output: '/tmp/xiaoyuzhou-transcripts',
|
|
187
187
|
json: false,
|
package/clis/yollomi/models.js
CHANGED
|
@@ -10,7 +10,7 @@ cli({
|
|
|
10
10
|
{ name: 'type', default: 'all', choices: ['all', 'image', 'video', 'tool'], help: 'Filter by model type' },
|
|
11
11
|
],
|
|
12
12
|
columns: ['type', 'model', 'credits', 'description'],
|
|
13
|
-
func: async (
|
|
13
|
+
func: async (kwargs) => {
|
|
14
14
|
const filter = kwargs.type;
|
|
15
15
|
const rows = [];
|
|
16
16
|
if (filter === 'all' || filter === 'image') {
|
package/clis/youtube/channel.js
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
5
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
6
|
+
|
|
7
|
+
export function extractSelectedRichGridContents(browseData) {
|
|
8
|
+
const tabs = browseData?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
|
|
9
|
+
const readRichGrid = (tab) => tab?.tabRenderer?.content?.richGridRenderer?.contents;
|
|
10
|
+
const selectedTab = tabs.find(t => t?.tabRenderer?.selected);
|
|
11
|
+
const selectedContents = readRichGrid(selectedTab);
|
|
12
|
+
if (Array.isArray(selectedContents))
|
|
13
|
+
return selectedContents;
|
|
14
|
+
const fallbackContents = readRichGrid(tabs.find(t => {
|
|
15
|
+
const contents = readRichGrid(t);
|
|
16
|
+
return Array.isArray(contents) && contents.length > 0;
|
|
17
|
+
})) || readRichGrid(tabs.find(t => Array.isArray(readRichGrid(t))));
|
|
18
|
+
return Array.isArray(fallbackContents) ? fallbackContents : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
cli({
|
|
7
22
|
site: 'youtube',
|
|
8
23
|
name: 'channel',
|
|
@@ -27,6 +42,7 @@ cli({
|
|
|
27
42
|
const apiKey = cfg.INNERTUBE_API_KEY;
|
|
28
43
|
const context = cfg.INNERTUBE_CONTEXT;
|
|
29
44
|
if (!apiKey || !context) return {error: 'YouTube config not found'};
|
|
45
|
+
const extractSelectedRichGridContents = ${extractSelectedRichGridContents.toString()};
|
|
30
46
|
|
|
31
47
|
// Resolve handle to browseId if needed
|
|
32
48
|
let browseId = channelId;
|
|
@@ -133,7 +149,10 @@ cli({
|
|
|
133
149
|
});
|
|
134
150
|
if (videosResp.ok) {
|
|
135
151
|
const videosData = await videosResp.json();
|
|
136
|
-
|
|
152
|
+
// The InnerTube response includes ALL tabs (Home/Videos/Shorts/...),
|
|
153
|
+
// not just the requested one. Prefer the selected tab, but keep
|
|
154
|
+
// older single-tab responses working when YouTube omits selected.
|
|
155
|
+
const richGrid = extractSelectedRichGridContents(videosData);
|
|
137
156
|
for (const item of richGrid) {
|
|
138
157
|
if (recentVideos.length >= limit) break;
|
|
139
158
|
const v = item.richItemRenderer?.content?.videoRenderer;
|
|
@@ -183,3 +202,7 @@ cli({
|
|
|
183
202
|
return rows;
|
|
184
203
|
},
|
|
185
204
|
});
|
|
205
|
+
|
|
206
|
+
export const __test__ = {
|
|
207
|
+
extractSelectedRichGridContents,
|
|
208
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './channel.js';
|
|
3
|
+
|
|
4
|
+
function tab(title, contents, selected = false) {
|
|
5
|
+
return {
|
|
6
|
+
tabRenderer: {
|
|
7
|
+
title,
|
|
8
|
+
selected,
|
|
9
|
+
content: {
|
|
10
|
+
richGridRenderer: {
|
|
11
|
+
contents,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function browseData(tabs) {
|
|
19
|
+
return {
|
|
20
|
+
contents: {
|
|
21
|
+
twoColumnBrowseResultsRenderer: {
|
|
22
|
+
tabs,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('youtube channel helpers', () => {
|
|
29
|
+
it('uses the selected rich-grid tab instead of the first tab', () => {
|
|
30
|
+
const home = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'home' } } } }];
|
|
31
|
+
const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'videos' } } } }];
|
|
32
|
+
|
|
33
|
+
expect(__test__.extractSelectedRichGridContents(browseData([
|
|
34
|
+
tab('Home', home),
|
|
35
|
+
tab('Videos', videos, true),
|
|
36
|
+
]))).toBe(videos);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to the first non-empty rich-grid tab when no tab is selected', () => {
|
|
40
|
+
const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'only' } } } }];
|
|
41
|
+
|
|
42
|
+
expect(__test__.extractSelectedRichGridContents(browseData([
|
|
43
|
+
tab('Home', []),
|
|
44
|
+
tab('Videos', videos),
|
|
45
|
+
]))).toBe(videos);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('is self-contained for browser evaluate injection', () => {
|
|
49
|
+
const extractSelectedRichGridContents = Function(
|
|
50
|
+
`return ${__test__.extractSelectedRichGridContents.toString()}`
|
|
51
|
+
)();
|
|
52
|
+
const videos = [{ richItemRenderer: { content: { videoRenderer: { videoId: 'serialized' } } } }];
|
|
53
|
+
|
|
54
|
+
expect(extractSelectedRichGridContents(browseData([
|
|
55
|
+
tab('Home', []),
|
|
56
|
+
tab('Videos', videos, true),
|
|
57
|
+
]))).toEqual(videos);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/clis/zhihu/answer.js
CHANGED
|
@@ -2,13 +2,12 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { assertAllowedKinds, parseTarget } from './target.js';
|
|
4
4
|
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
|
|
5
|
-
const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';
|
|
6
5
|
cli({
|
|
7
6
|
site: 'zhihu',
|
|
8
7
|
name: 'answer',
|
|
9
8
|
description: 'Answer a Zhihu question',
|
|
10
9
|
domain: 'www.zhihu.com',
|
|
11
|
-
strategy: Strategy.
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
12
11
|
browser: true,
|
|
13
12
|
args: [
|
|
14
13
|
{ name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
|
|
@@ -23,171 +22,31 @@ cli({
|
|
|
23
22
|
requireExecute(kwargs);
|
|
24
23
|
const rawTarget = String(kwargs.target);
|
|
25
24
|
const target = assertAllowedKinds('answer', parseTarget(rawTarget));
|
|
26
|
-
const questionTarget = target;
|
|
27
25
|
const payload = await resolvePayload(kwargs);
|
|
28
26
|
await page.goto(target.url);
|
|
27
|
+
await page.wait(3);
|
|
29
28
|
const authorIdentity = await resolveCurrentUserIdentity(page);
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return slugs.length === 1 ? slugs[0] : null;
|
|
40
|
-
};
|
|
41
|
-
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
|
|
42
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
43
|
-
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
44
|
-
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
45
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
46
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
47
|
-
return { editor, container, text, submitButton, nestedComment };
|
|
48
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
49
|
-
const hasExistingAnswerByCurrentUser = Array.from(document.querySelectorAll('[data-zop-question-answer], article')).some((node) => {
|
|
50
|
-
return readAnswerAuthorSlug(node) === currentUserSlug;
|
|
51
|
-
});
|
|
52
|
-
return {
|
|
53
|
-
entryPathSafe: composerCandidates.length === 1
|
|
54
|
-
&& !String(composerCandidates[0].text || '').trim()
|
|
55
|
-
&& !restoredDraft
|
|
56
|
-
&& !hasExistingAnswerByCurrentUser,
|
|
57
|
-
hasExistingAnswerByCurrentUser,
|
|
58
|
-
};
|
|
59
|
-
})()`);
|
|
60
|
-
if (entryPath.hasExistingAnswerByCurrentUser) {
|
|
61
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'zhihu answer only supports creating a new answer when the current user has not already answered this question');
|
|
62
|
-
}
|
|
63
|
-
if (!entryPath.entryPathSafe) {
|
|
64
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor entry path was not proven side-effect free');
|
|
65
|
-
}
|
|
66
|
-
const editorState = await page.evaluate(`(async () => {
|
|
67
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
68
|
-
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
69
|
-
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
|
|
70
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
71
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
72
|
-
return { editor, container, text, submitButton, nestedComment };
|
|
73
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
74
|
-
if (composerCandidates.length !== 1) return { editorState: 'unsafe', anonymousMode: 'unknown' };
|
|
75
|
-
const { editor, text } = composerCandidates[0];
|
|
76
|
-
const anonymousLabeledControl =
|
|
77
|
-
(composerCandidates[0].container && composerCandidates[0].container.querySelector('[aria-label*="匿名"], [title*="匿名"]'))
|
|
78
|
-
|| Array.from((composerCandidates[0].container || document).querySelectorAll('label, button, [role="switch"], [role="checkbox"]')).find((node) => /匿名/.test(node.textContent || ''))
|
|
79
|
-
|| null;
|
|
80
|
-
const anonymousToggle =
|
|
81
|
-
anonymousLabeledControl?.matches?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
|
|
82
|
-
? anonymousLabeledControl
|
|
83
|
-
: anonymousLabeledControl?.querySelector?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
|
|
84
|
-
|| null;
|
|
85
|
-
let anonymousMode = 'unknown';
|
|
86
|
-
if (anonymousToggle) {
|
|
87
|
-
const ariaChecked = anonymousToggle.getAttribute && anonymousToggle.getAttribute('aria-checked');
|
|
88
|
-
const checked = 'checked' in anonymousToggle ? anonymousToggle.checked === true : false;
|
|
89
|
-
if (ariaChecked === 'true' || checked) anonymousMode = 'on';
|
|
90
|
-
else if (ariaChecked === 'false' || ('checked' in anonymousToggle && anonymousToggle.checked === false)) anonymousMode = 'off';
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
editorState: editor && !text.trim() ? 'fresh_empty' : 'unsafe',
|
|
94
|
-
anonymousMode,
|
|
95
|
-
};
|
|
96
|
-
})()`);
|
|
97
|
-
if (editorState.editorState !== 'fresh_empty') {
|
|
98
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor was not fresh and empty');
|
|
99
|
-
}
|
|
100
|
-
if (editorState.anonymousMode !== 'off') {
|
|
101
|
-
throw new CliError('ACTION_NOT_AVAILABLE', 'Anonymous answer mode could not be proven off for zhihu answer');
|
|
102
|
-
}
|
|
103
|
-
const editorCheck = await page.evaluate(`(async () => {
|
|
104
|
-
const textToInsert = ${JSON.stringify(payload)};
|
|
105
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
106
|
-
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
107
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
108
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
109
|
-
return { editor, container, submitButton, nestedComment };
|
|
110
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
111
|
-
if (composerCandidates.length !== 1) return { editorContent: '', bodyMatches: false };
|
|
112
|
-
const { editor } = composerCandidates[0];
|
|
113
|
-
editor.focus();
|
|
114
|
-
if ('value' in editor) {
|
|
115
|
-
editor.value = '';
|
|
116
|
-
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
117
|
-
editor.value = textToInsert;
|
|
118
|
-
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
119
|
-
} else {
|
|
120
|
-
editor.textContent = '';
|
|
121
|
-
document.execCommand('insertText', false, textToInsert);
|
|
122
|
-
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
|
|
123
|
-
}
|
|
124
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
125
|
-
const content = 'value' in editor ? editor.value : (editor.textContent || '');
|
|
126
|
-
return { editorContent: content, bodyMatches: content === textToInsert };
|
|
127
|
-
})()`);
|
|
128
|
-
if (editorCheck.editorContent !== payload || !editorCheck.bodyMatches) {
|
|
129
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Answer editor content did not exactly match the requested payload before publish');
|
|
130
|
-
}
|
|
131
|
-
const proof = await page.evaluate(`(async () => {
|
|
132
|
-
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
|
|
133
|
-
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
|
|
134
|
-
const readAnswerAuthorSlug = (node) => {
|
|
135
|
-
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
|
|
136
|
-
const slugs = Array.from(new Set(authorScopes
|
|
137
|
-
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
|
|
138
|
-
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
|
|
139
|
-
.filter(Boolean)));
|
|
140
|
-
return slugs.length === 1 ? slugs[0] : null;
|
|
141
|
-
};
|
|
142
|
-
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
|
|
143
|
-
const container = editor.closest('form, [role="dialog"], .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
|
|
144
|
-
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
145
|
-
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
|
|
146
|
-
return { editor, container, submitButton, nestedComment };
|
|
147
|
-
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
|
|
148
|
-
if (composerCandidates.length !== 1) return { createdTarget: null, createdUrl: null, authorIdentity: null, bodyMatches: false };
|
|
149
|
-
const submitScope = composerCandidates[0].container || document;
|
|
150
|
-
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
|
|
151
|
-
submit && submit.click();
|
|
152
|
-
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
153
|
-
const href = location.href;
|
|
154
|
-
const match = href.match(/question\\/(\\d+)\\/answer\\/(\\d+)/);
|
|
155
|
-
const targetHref = match ? '/question/' + match[1] + '/answer/' + match[2] : null;
|
|
156
|
-
const answerContainer = targetHref
|
|
157
|
-
? Array.from(document.querySelectorAll('[data-zop-question-answer], article')).find((node) => {
|
|
158
|
-
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
|
|
159
|
-
if (dataAnswerId && dataAnswerId.includes(match[2])) return true;
|
|
160
|
-
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
|
|
161
|
-
const hrefValue = link.getAttribute('href') || '';
|
|
162
|
-
return hrefValue.includes(targetHref);
|
|
29
|
+
const apiResult = await page.evaluate(`(async () => {
|
|
30
|
+
var questionId = ${JSON.stringify(target.id)};
|
|
31
|
+
var content = ${JSON.stringify(payload)};
|
|
32
|
+
var url = 'https://www.zhihu.com/api/v4/questions/' + questionId + '/answers';
|
|
33
|
+
var resp = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
credentials: 'include',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ content: content, reshipment_settings: 'disallowed' }),
|
|
163
38
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|| answerContainer;
|
|
172
|
-
const bodyText = normalize(bodyNode?.textContent || '');
|
|
173
|
-
return match
|
|
174
|
-
? {
|
|
175
|
-
createdTarget: 'answer:' + match[1] + ':' + match[2],
|
|
176
|
-
createdUrl: href,
|
|
177
|
-
authorIdentity: authorSlug,
|
|
178
|
-
bodyMatches: bodyText === normalize(${JSON.stringify(payload)}),
|
|
179
|
-
}
|
|
180
|
-
: { createdTarget: null, createdUrl: null, authorIdentity: authorSlug, bodyMatches: false };
|
|
181
|
-
})()`);
|
|
182
|
-
if (proof.authorIdentity !== authorIdentity) {
|
|
183
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Answer was created but authorship could not be proven for the frozen current user');
|
|
184
|
-
}
|
|
185
|
-
if (!proof.createdTarget || !proof.bodyMatches || proof.createdTarget.split(':')[1] !== questionTarget.id) {
|
|
186
|
-
throw new CliError('OUTCOME_UNKNOWN', 'Created answer proof did not match the requested question or payload');
|
|
39
|
+
var data = await resp.json();
|
|
40
|
+
if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
|
|
41
|
+
if (!data || !data.id) return { ok: false, status: resp.status, message: 'Answer API response did not include a created answer id' };
|
|
42
|
+
return { ok: true, id: String(data.id), url: data.url || ('https://www.zhihu.com/question/' + questionId + '/answer/' + data.id) };
|
|
43
|
+
})()`);
|
|
44
|
+
if (!apiResult?.ok) {
|
|
45
|
+
throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create answer');
|
|
187
46
|
}
|
|
188
|
-
return buildResultRow(`Answered question ${
|
|
189
|
-
created_target:
|
|
190
|
-
created_url:
|
|
47
|
+
return buildResultRow(`Answered question ${target.id}`, target.kind, rawTarget, 'created', {
|
|
48
|
+
created_target: 'answer:' + target.id + ':' + apiResult.id,
|
|
49
|
+
created_url: apiResult.url,
|
|
191
50
|
author_identity: authorIdentity,
|
|
192
51
|
});
|
|
193
52
|
},
|