@jackwener/opencli 1.7.3 → 1.7.5
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 +81 -59
- package/README.zh-CN.md +93 -67
- package/cli-manifest.json +5015 -2975
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -0
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +53 -0
- package/clis/xiaoyuzhou/download.test.js +135 -0
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +3 -0
- package/dist/src/browser/bridge.js +52 -15
- package/dist/src/browser/cdp.js +2 -1
- package/dist/src/browser/daemon-client.d.ts +7 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +20 -3
- package/dist/src/browser/page.d.ts +18 -5
- package/dist/src/browser/page.js +96 -15
- package/dist/src/browser/page.test.js +158 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +272 -187
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +10 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +8 -9
- package/dist/src/plugin.js +24 -28
- package/dist/src/plugin.test.js +16 -60
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +15 -6
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
|
+
import type { IPage } from '@jackwener/opencli/types';
|
|
6
|
+
|
|
7
|
+
const GROK_URL = 'https://grok.com/';
|
|
8
|
+
const NO_IMAGE_PREFIX = '[NO IMAGE]';
|
|
9
|
+
const BLOCKED_PREFIX = '[BLOCKED]';
|
|
10
|
+
const SESSION_HINT = 'Likely login/auth/challenge/session issue in the existing grok.com browser session.';
|
|
11
|
+
|
|
12
|
+
type SendResult = {
|
|
13
|
+
ok?: boolean;
|
|
14
|
+
msg?: string;
|
|
15
|
+
reason?: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type BubbleImage = {
|
|
20
|
+
src: string;
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type BubbleImageSet = BubbleImage[];
|
|
26
|
+
|
|
27
|
+
type FetchResult = {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
base64?: string;
|
|
30
|
+
contentType?: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function normalizeBooleanFlag(value: unknown): boolean {
|
|
35
|
+
if (typeof value === 'boolean') return value;
|
|
36
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
37
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dedupeBySrc(images: BubbleImage[]): BubbleImage[] {
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const out: BubbleImage[] = [];
|
|
43
|
+
for (const img of images) {
|
|
44
|
+
if (!img.src || seen.has(img.src)) continue;
|
|
45
|
+
seen.add(img.src);
|
|
46
|
+
out.push(img);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function imagesSignature(images: BubbleImage[]): string {
|
|
52
|
+
return images.map(i => i.src).sort().join('|');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extFromContentType(ct?: string): string {
|
|
56
|
+
if (!ct) return 'jpg';
|
|
57
|
+
if (ct.includes('png')) return 'png';
|
|
58
|
+
if (ct.includes('webp')) return 'webp';
|
|
59
|
+
if (ct.includes('gif')) return 'gif';
|
|
60
|
+
return 'jpg';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildFilename(src: string, ct?: string): string {
|
|
64
|
+
const ext = extFromContentType(ct);
|
|
65
|
+
const hash = crypto.createHash('sha1').update(src).digest('hex').slice(0, 12);
|
|
66
|
+
return `grok-${Date.now()}-${hash}.${ext}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Check whether the tab is already on grok.com (any path). */
|
|
70
|
+
async function isOnGrok(page: IPage): Promise<boolean> {
|
|
71
|
+
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
72
|
+
if (typeof url !== 'string' || !url) return false;
|
|
73
|
+
try {
|
|
74
|
+
const hostname = new URL(url).hostname;
|
|
75
|
+
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function tryStartFreshChat(page: IPage): Promise<void> {
|
|
82
|
+
await page.evaluate(`(() => {
|
|
83
|
+
const isVisible = (node) => {
|
|
84
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
85
|
+
const rect = node.getBoundingClientRect();
|
|
86
|
+
const style = window.getComputedStyle(node);
|
|
87
|
+
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
|
88
|
+
};
|
|
89
|
+
const candidates = Array.from(document.querySelectorAll('a, button')).filter(node => {
|
|
90
|
+
if (!isVisible(node)) return false;
|
|
91
|
+
const text = (node.textContent || '').trim().toLowerCase();
|
|
92
|
+
const aria = (node.getAttribute('aria-label') || '').trim().toLowerCase();
|
|
93
|
+
const href = node.getAttribute('href') || '';
|
|
94
|
+
return text.includes('new chat')
|
|
95
|
+
|| text.includes('new conversation')
|
|
96
|
+
|| aria.includes('new chat')
|
|
97
|
+
|| aria.includes('new conversation')
|
|
98
|
+
|| href === '/';
|
|
99
|
+
});
|
|
100
|
+
const target = candidates[0];
|
|
101
|
+
if (target instanceof HTMLElement) target.click();
|
|
102
|
+
})()`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function sendPrompt(page: IPage, prompt: string): Promise<SendResult> {
|
|
106
|
+
const promptJson = JSON.stringify(prompt);
|
|
107
|
+
return page.evaluate(`(async () => {
|
|
108
|
+
try {
|
|
109
|
+
const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
110
|
+
const composerSelector = '.ProseMirror[contenteditable="true"]';
|
|
111
|
+
const isVisibleEnabledSubmit = (node) => {
|
|
112
|
+
if (!(node instanceof HTMLButtonElement)) return false;
|
|
113
|
+
const rect = node.getBoundingClientRect();
|
|
114
|
+
const style = window.getComputedStyle(node);
|
|
115
|
+
return !node.disabled
|
|
116
|
+
&& rect.width > 0
|
|
117
|
+
&& rect.height > 0
|
|
118
|
+
&& style.visibility !== 'hidden'
|
|
119
|
+
&& style.display !== 'none';
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
let pm = null;
|
|
123
|
+
let box = null;
|
|
124
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
125
|
+
const composer = document.querySelector(composerSelector);
|
|
126
|
+
if (composer instanceof HTMLElement) {
|
|
127
|
+
pm = composer;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const textarea = document.querySelector('textarea');
|
|
132
|
+
if (textarea instanceof HTMLTextAreaElement) {
|
|
133
|
+
box = textarea;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await waitFor(1000);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Prefer the ProseMirror composer when present (current grok.com UI).
|
|
141
|
+
if (pm && pm.editor && pm.editor.commands) {
|
|
142
|
+
try {
|
|
143
|
+
if (pm.editor.commands.clearContent) pm.editor.commands.clearContent();
|
|
144
|
+
pm.editor.commands.focus();
|
|
145
|
+
pm.editor.commands.insertContent(${promptJson});
|
|
146
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
147
|
+
const sbtn = Array.from(document.querySelectorAll('button[aria-label="Submit"], button[aria-label="\\u63d0\\u4ea4"]'))
|
|
148
|
+
.find(isVisibleEnabledSubmit);
|
|
149
|
+
if (sbtn) {
|
|
150
|
+
sbtn.click();
|
|
151
|
+
return { ok: true, msg: 'pm-submit' };
|
|
152
|
+
}
|
|
153
|
+
await waitFor(500);
|
|
154
|
+
}
|
|
155
|
+
} catch (e) { /* fall through to textarea */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Fallback: legacy textarea composer.
|
|
159
|
+
if (!box) return { ok: false, msg: 'no composer (neither ProseMirror nor textarea)' };
|
|
160
|
+
box.focus(); box.value = '';
|
|
161
|
+
document.execCommand('selectAll');
|
|
162
|
+
document.execCommand('insertText', false, ${promptJson});
|
|
163
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
164
|
+
const btn = Array.from(document.querySelectorAll('button[aria-label="\\u63d0\\u4ea4"], button[aria-label="Submit"]'))
|
|
165
|
+
.find(isVisibleEnabledSubmit);
|
|
166
|
+
if (btn) {
|
|
167
|
+
btn.click();
|
|
168
|
+
return { ok: true, msg: 'clicked' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sub = Array.from(document.querySelectorAll('button[type="submit"]'))
|
|
172
|
+
.find(isVisibleEnabledSubmit);
|
|
173
|
+
if (sub) {
|
|
174
|
+
sub.click();
|
|
175
|
+
return { ok: true, msg: 'clicked-submit' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await waitFor(500);
|
|
179
|
+
}
|
|
180
|
+
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
|
181
|
+
return { ok: true, msg: 'enter' };
|
|
182
|
+
} catch (e) { return { ok: false, msg: e && e.toString ? e.toString() : String(e) }; }
|
|
183
|
+
})()`) as Promise<SendResult>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Read <img> elements from all message bubbles so callers can filter by baseline. */
|
|
187
|
+
async function getBubbleImageSets(page: IPage): Promise<BubbleImageSet[]> {
|
|
188
|
+
const result = await page.evaluate(`(() => {
|
|
189
|
+
const bubbles = document.querySelectorAll('div.message-bubble, [data-testid="message-bubble"]');
|
|
190
|
+
return Array.from(bubbles).map(bubble => Array.from(bubble.querySelectorAll('img'))
|
|
191
|
+
.map(img => ({
|
|
192
|
+
src: img.currentSrc || img.src || '',
|
|
193
|
+
w: img.naturalWidth || img.width || 0,
|
|
194
|
+
h: img.naturalHeight || img.height || 0,
|
|
195
|
+
}))
|
|
196
|
+
.filter(i => i.src && /^https?:/.test(i.src))
|
|
197
|
+
// Ignore tiny UI/avatar images that may live in the bubble chrome.
|
|
198
|
+
.filter(i => (i.w === 0 || i.w >= 128) && (i.h === 0 || i.h >= 128)));
|
|
199
|
+
})()`) as BubbleImageSet[] | undefined;
|
|
200
|
+
|
|
201
|
+
const raw = Array.isArray(result) ? result : [];
|
|
202
|
+
return raw.map(dedupeBySrc);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function pickLatestImageCandidate(
|
|
206
|
+
bubbleImageSets: BubbleImageSet[],
|
|
207
|
+
baselineCount: number,
|
|
208
|
+
): BubbleImage[] {
|
|
209
|
+
const freshSets = bubbleImageSets.slice(Math.max(0, baselineCount));
|
|
210
|
+
for (let i = freshSets.length - 1; i >= 0; i -= 1) {
|
|
211
|
+
if (freshSets[i].length) return freshSets[i];
|
|
212
|
+
}
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Download through the browser's fetch so grok.com cookies and referer are
|
|
217
|
+
// attached automatically — assets.grok.com is gated by Cloudflare and will
|
|
218
|
+
// refuse direct curl/node downloads.
|
|
219
|
+
async function fetchImageAsBase64(page: IPage, url: string): Promise<FetchResult> {
|
|
220
|
+
const urlJson = JSON.stringify(url);
|
|
221
|
+
return page.evaluate(`(async () => {
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(${urlJson}, { credentials: 'include', referrer: 'https://grok.com/' });
|
|
224
|
+
if (!res.ok) return { ok: false, error: 'HTTP ' + res.status };
|
|
225
|
+
const blob = await res.blob();
|
|
226
|
+
const buf = await blob.arrayBuffer();
|
|
227
|
+
const bytes = new Uint8Array(buf);
|
|
228
|
+
let binary = '';
|
|
229
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
230
|
+
return { ok: true, base64: btoa(binary), contentType: blob.type || 'image/jpeg' };
|
|
231
|
+
} catch (e) { return { ok: false, error: e && e.message || String(e) }; }
|
|
232
|
+
})()`) as Promise<FetchResult>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function saveImages(
|
|
236
|
+
page: IPage,
|
|
237
|
+
images: BubbleImage[],
|
|
238
|
+
outDir: string,
|
|
239
|
+
): Promise<Array<BubbleImage & { path: string }>> {
|
|
240
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
241
|
+
const results: Array<BubbleImage & { path: string }> = [];
|
|
242
|
+
for (const img of images) {
|
|
243
|
+
const fetched = await fetchImageAsBase64(page, img.src);
|
|
244
|
+
if (!fetched || !fetched.ok) {
|
|
245
|
+
results.push({ ...img, path: `[DOWNLOAD FAILED] ${fetched?.error || 'unknown'}` });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const filepath = path.join(outDir, buildFilename(img.src, fetched.contentType));
|
|
249
|
+
fs.writeFileSync(filepath, Buffer.from(fetched.base64 || '', 'base64'));
|
|
250
|
+
results.push({ ...img, path: filepath });
|
|
251
|
+
}
|
|
252
|
+
return results;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function toRow(img: BubbleImage, savedPath = '') {
|
|
256
|
+
return { url: img.src, width: img.w, height: img.h, path: savedPath };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const imageCommand = cli({
|
|
260
|
+
site: 'grok',
|
|
261
|
+
name: 'image',
|
|
262
|
+
description: 'Generate images on grok.com and return image URLs',
|
|
263
|
+
domain: 'grok.com',
|
|
264
|
+
strategy: Strategy.COOKIE,
|
|
265
|
+
browser: true,
|
|
266
|
+
args: [
|
|
267
|
+
{ name: 'prompt', positional: true, type: 'string', required: true, help: 'Image generation prompt' },
|
|
268
|
+
{ name: 'timeout', type: 'int', default: 240, help: 'Max seconds to wait for the image (default: 240)' },
|
|
269
|
+
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending (default: false)' },
|
|
270
|
+
{ name: 'count', type: 'int', default: 1, help: 'Minimum images to wait for before returning (default: 1)' },
|
|
271
|
+
{ name: 'out', type: 'string', default: '', help: 'Directory to save downloaded images (uses browser session to bypass auth)' },
|
|
272
|
+
],
|
|
273
|
+
columns: ['url', 'width', 'height', 'path'],
|
|
274
|
+
func: async (page: IPage, kwargs: Record<string, any>) => {
|
|
275
|
+
const prompt = kwargs.prompt as string;
|
|
276
|
+
const timeoutMs = ((kwargs.timeout as number) || 240) * 1000;
|
|
277
|
+
const newChat = normalizeBooleanFlag(kwargs.new);
|
|
278
|
+
const minCount = Math.max(1, Number(kwargs.count || 1));
|
|
279
|
+
const outDir = (kwargs.out || '').toString().trim();
|
|
280
|
+
|
|
281
|
+
if (newChat) {
|
|
282
|
+
await page.goto(GROK_URL);
|
|
283
|
+
await page.wait(2);
|
|
284
|
+
await tryStartFreshChat(page);
|
|
285
|
+
await page.wait(2);
|
|
286
|
+
} else if (!(await isOnGrok(page))) {
|
|
287
|
+
await page.goto(GROK_URL);
|
|
288
|
+
await page.wait(3);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const baselineBubbleCount = (await getBubbleImageSets(page)).length;
|
|
292
|
+
const sendResult = await sendPrompt(page, prompt);
|
|
293
|
+
if (!sendResult || !sendResult.ok) {
|
|
294
|
+
return [{
|
|
295
|
+
url: `${BLOCKED_PREFIX} send failed: ${JSON.stringify(sendResult)}. ${SESSION_HINT}`,
|
|
296
|
+
width: 0,
|
|
297
|
+
height: 0,
|
|
298
|
+
path: '',
|
|
299
|
+
}];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const startTime = Date.now();
|
|
303
|
+
let lastSignature = '';
|
|
304
|
+
let stableCount = 0;
|
|
305
|
+
let lastImages: BubbleImage[] = [];
|
|
306
|
+
|
|
307
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
308
|
+
await page.wait(3);
|
|
309
|
+
const bubbleImageSets = await getBubbleImageSets(page);
|
|
310
|
+
const images = pickLatestImageCandidate(bubbleImageSets, baselineBubbleCount);
|
|
311
|
+
|
|
312
|
+
if (images.length >= minCount) {
|
|
313
|
+
const signature = imagesSignature(images);
|
|
314
|
+
if (signature === lastSignature) {
|
|
315
|
+
stableCount += 1;
|
|
316
|
+
// Require two consecutive stable reads (~6s) before declaring done.
|
|
317
|
+
if (stableCount >= 2) {
|
|
318
|
+
if (outDir) {
|
|
319
|
+
const saved = await saveImages(page, images, outDir);
|
|
320
|
+
return saved.map(s => toRow(s, s.path));
|
|
321
|
+
}
|
|
322
|
+
return images.map(i => toRow(i));
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
stableCount = 0;
|
|
326
|
+
lastSignature = signature;
|
|
327
|
+
lastImages = images;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (lastImages.length) {
|
|
333
|
+
if (outDir) {
|
|
334
|
+
const saved = await saveImages(page, lastImages, outDir);
|
|
335
|
+
return saved.map(s => toRow(s, s.path));
|
|
336
|
+
}
|
|
337
|
+
return lastImages.map(i => toRow(i));
|
|
338
|
+
}
|
|
339
|
+
return [{
|
|
340
|
+
url: `${NO_IMAGE_PREFIX} No image appeared within ${Math.round(timeoutMs / 1000)}s.`,
|
|
341
|
+
width: 0,
|
|
342
|
+
height: 0,
|
|
343
|
+
path: '',
|
|
344
|
+
}];
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
export const __test__ = {
|
|
349
|
+
normalizeBooleanFlag,
|
|
350
|
+
isOnGrok,
|
|
351
|
+
dedupeBySrc,
|
|
352
|
+
imagesSignature,
|
|
353
|
+
extFromContentType,
|
|
354
|
+
buildFilename,
|
|
355
|
+
pickLatestImageCandidate,
|
|
356
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'companies',
|
|
6
|
+
description: 'Hot companies for interview prep',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'job', type: 'str', default: '11002', help: 'Job ID (11002=Java, 11003=C++, 11200=Backend, 11203=QA, 11201=Frontend)' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'company', 'companyId'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://gw-c.nowcoder.com/api/sparta/company-question/hot-company-list?jobId=${{ args.job }}' } },
|
|
16
|
+
{ select: 'data.result' },
|
|
17
|
+
{ map: {
|
|
18
|
+
rank: '${{ index + 1 }}',
|
|
19
|
+
company: '${{ item.companyName }}',
|
|
20
|
+
companyId: '${{ item.companyId }}',
|
|
21
|
+
} },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'creators',
|
|
6
|
+
description: 'Top content creators leaderboard',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'nickname', 'school', 'level', 'heat', 'tag'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://gw-c.nowcoder.com/api/sparta/content/creator/top-list' } },
|
|
16
|
+
{ select: 'data.result' },
|
|
17
|
+
{ map: {
|
|
18
|
+
rank: '${{ index + 1 }}',
|
|
19
|
+
nickname: `\${{ item.userBrief?.nickname || '' }}`,
|
|
20
|
+
school: `\${{ item.userBrief?.educationInfo || '' }}`,
|
|
21
|
+
level: `\${{ item.userBrief?.honorLevelName || '' }}`,
|
|
22
|
+
heat: '${{ item.hotValue }}',
|
|
23
|
+
tag: `\${{ item.tag || '' }}`,
|
|
24
|
+
} },
|
|
25
|
+
{ limit: '${{ args.limit }}' },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'detail',
|
|
6
|
+
description: 'Post detail view (supports ID / UUID / URL)',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
args: [
|
|
9
|
+
{ name: 'id', positional: true, required: true, help: 'Post ID, UUID, or URL' },
|
|
10
|
+
],
|
|
11
|
+
columns: ['title', 'author', 'school', 'content', 'likes', 'comments', 'views', 'time', 'location'],
|
|
12
|
+
pipeline: [
|
|
13
|
+
{ navigate: 'https://www.nowcoder.com' },
|
|
14
|
+
{ evaluate: `(async () => {
|
|
15
|
+
const raw = \${{ args.id | json }};
|
|
16
|
+
const base = 'https://gw-c.nowcoder.com';
|
|
17
|
+
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|
18
|
+
|
|
19
|
+
let id = raw;
|
|
20
|
+
const urlMatch = raw.match(/discuss\\/(\\d+)/);
|
|
21
|
+
if (urlMatch) id = urlMatch[1];
|
|
22
|
+
|
|
23
|
+
let data = null;
|
|
24
|
+
|
|
25
|
+
if (/[a-f]/.test(id) && id.length > 20) {
|
|
26
|
+
const r = await fetch(base + '/api/sparta/detail/moment-data/detail/' + id, {credentials: 'include'});
|
|
27
|
+
const d = await r.json();
|
|
28
|
+
if (d.success && d.data) data = d.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!data && /^\\d+$/.test(id)) {
|
|
32
|
+
const r = await fetch(base + '/api/sparta/detail/content-data/detail/' + id, {credentials: 'include'});
|
|
33
|
+
const d = await r.json();
|
|
34
|
+
if (d.success && d.data) data = d.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!data && /^\\d+$/.test(id)) {
|
|
38
|
+
const r = await fetch(base + '/api/sparta/detail/moment-data/detail/' + id, {credentials: 'include'});
|
|
39
|
+
const d = await r.json();
|
|
40
|
+
if (d.success && d.data) data = d.data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!data) throw new Error('Post not found: ' + id);
|
|
44
|
+
|
|
45
|
+
const user = data.userBrief || {};
|
|
46
|
+
const freq = data.frequencyData || {};
|
|
47
|
+
return [{
|
|
48
|
+
title: data.title || '(untitled)',
|
|
49
|
+
author: user.nickname || '',
|
|
50
|
+
school: user.educationInfo || '',
|
|
51
|
+
content: strip(data.content || '').substring(0, 500),
|
|
52
|
+
likes: freq.likeCnt || 0,
|
|
53
|
+
comments: freq.commentCnt || freq.totalCommentCnt || 0,
|
|
54
|
+
views: freq.viewCnt || 0,
|
|
55
|
+
time: data.createdAt ? new Date(data.createdAt).toISOString().slice(0, 19) : '',
|
|
56
|
+
location: data.ip4Location || '',
|
|
57
|
+
}];
|
|
58
|
+
})()
|
|
59
|
+
` },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'experience',
|
|
6
|
+
description: 'Interview experience posts',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
args: [
|
|
9
|
+
{ name: 'page', type: 'int', default: 1, help: 'Page number' },
|
|
10
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Number of items' },
|
|
11
|
+
],
|
|
12
|
+
columns: ['rank', 'title', 'author', 'school', 'likes', 'comments', 'views', 'id'],
|
|
13
|
+
pipeline: [
|
|
14
|
+
{ navigate: 'https://www.nowcoder.com' },
|
|
15
|
+
{ evaluate: `(async () => {
|
|
16
|
+
const page = \${{ args.page }};
|
|
17
|
+
const limit = \${{ args.limit }};
|
|
18
|
+
const r = await fetch('https://gw-c.nowcoder.com/api/sparta/home/tab/content?tabId=818&categoryType=1&pageNo=' + page + '&pageSize=' + limit, {credentials: 'include'});
|
|
19
|
+
const d = await r.json();
|
|
20
|
+
if (!d.success) throw new Error(d.msg || 'API failed');
|
|
21
|
+
return (d.data?.records || []).map((item, i) => ({
|
|
22
|
+
rank: i + 1,
|
|
23
|
+
title: item.contentData?.title || '',
|
|
24
|
+
author: item.userBrief?.nickname || '',
|
|
25
|
+
school: item.userBrief?.educationInfo || '',
|
|
26
|
+
likes: item.frequencyData?.likeCnt || 0,
|
|
27
|
+
comments: item.frequencyData?.commentCnt || 0,
|
|
28
|
+
views: item.frequencyData?.viewCnt || 0,
|
|
29
|
+
id: item.contentData?.uuid || item.contentData?.id || item.contentId || '',
|
|
30
|
+
}));
|
|
31
|
+
})()
|
|
32
|
+
` },
|
|
33
|
+
{ filter: 'item.title' },
|
|
34
|
+
{ limit: '${{ args.limit }}' },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'hot',
|
|
6
|
+
description: 'Hot search ranking',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'title', 'heat'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://gw-c.nowcoder.com/api/sparta/hot-search/hot-content' } },
|
|
16
|
+
{ select: 'data.hotQuery' },
|
|
17
|
+
{ map: {
|
|
18
|
+
rank: '${{ item.rank }}',
|
|
19
|
+
title: '${{ item.query }}',
|
|
20
|
+
heat: '${{ item.hotValue }}',
|
|
21
|
+
} },
|
|
22
|
+
{ limit: '${{ args.limit }}' },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'jobs',
|
|
6
|
+
description: 'Career category listing',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [],
|
|
11
|
+
columns: ['id', 'career', 'learners'],
|
|
12
|
+
pipeline: [
|
|
13
|
+
{ fetch: { url: 'https://gw-c.nowcoder.com/api/sparta/company-question/careerJobLevel1List' } },
|
|
14
|
+
{ select: 'data.careerJobSelectors' },
|
|
15
|
+
{ map: {
|
|
16
|
+
id: '${{ item.id }}',
|
|
17
|
+
career: '${{ item.name }}',
|
|
18
|
+
learners: `\${{ item.practiceCount || '' }}`,
|
|
19
|
+
} },
|
|
20
|
+
],
|
|
21
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'notifications',
|
|
6
|
+
description: 'Unread message summary',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
args: [],
|
|
9
|
+
columns: ['type', 'unread'],
|
|
10
|
+
pipeline: [
|
|
11
|
+
{ navigate: 'https://www.nowcoder.com' },
|
|
12
|
+
{ evaluate: `(async () => {
|
|
13
|
+
const r = await fetch('https://gw-c.nowcoder.com/api/sparta/message/pc/unread/detail', {credentials: 'include'});
|
|
14
|
+
const d = await r.json();
|
|
15
|
+
if (!d.success) throw new Error(d.msg || 'API failed');
|
|
16
|
+
const data = d.data;
|
|
17
|
+
return [
|
|
18
|
+
{type: 'system', unread: data.systemNotice?.unreadCount || 0},
|
|
19
|
+
{type: 'likes', unread: data.likeCollect?.unreadCount || 0},
|
|
20
|
+
{type: 'comments', unread: data.commentMessage?.unreadCount || 0},
|
|
21
|
+
{type: 'follows', unread: data.followMessage?.unreadCount || 0},
|
|
22
|
+
{type: 'messages', unread: data.privateMessage?.unreadCount || 0},
|
|
23
|
+
{type: 'job_apply', unread: data.nowPickJobApply?.unreadCount || 0},
|
|
24
|
+
{type: 'total', unread: data.total?.unreadCount || 0},
|
|
25
|
+
];
|
|
26
|
+
})()
|
|
27
|
+
` },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'papers',
|
|
6
|
+
description: 'Interview question bank by company and job',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
args: [
|
|
9
|
+
{ name: 'job', type: 'str', default: '11002', help: 'Job ID (11002=Java, 11003=C++, 11200=Backend, 11203=QA, 11201=Frontend)' },
|
|
10
|
+
{ name: 'company', type: 'str', default: '', help: 'Company ID (e.g. 139=Baidu, 138=Tencent, 239=Huawei)' },
|
|
11
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'title', 'company', 'practitioners'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ navigate: 'https://www.nowcoder.com' },
|
|
16
|
+
{ evaluate: `(async () => {
|
|
17
|
+
const jobId = parseInt(\${{ args.job | json }});
|
|
18
|
+
const companyId = \${{ args.company | json }};
|
|
19
|
+
const limit = \${{ args.limit }};
|
|
20
|
+
const body = {jobId, page: 1, pageSize: limit};
|
|
21
|
+
if (companyId) body.companyId = parseInt(companyId);
|
|
22
|
+
const r = await fetch('https://gw-c.nowcoder.com/api/sparta/company-question/get-paper-list', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
credentials: 'include',
|
|
25
|
+
headers: {'Content-Type': 'application/json'},
|
|
26
|
+
body: JSON.stringify(body)
|
|
27
|
+
});
|
|
28
|
+
const d = await r.json();
|
|
29
|
+
if (!d.success) throw new Error(d.msg || 'API failed');
|
|
30
|
+
return (d.data?.records || []).map((p, i) => ({
|
|
31
|
+
rank: i + 1,
|
|
32
|
+
title: p.paperName || '',
|
|
33
|
+
company: p.companyTag?.name || '',
|
|
34
|
+
practitioners: p.practiceCnt || 0,
|
|
35
|
+
}));
|
|
36
|
+
})()
|
|
37
|
+
` },
|
|
38
|
+
{ limit: '${{ args.limit }}' },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { cli } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'nowcoder',
|
|
5
|
+
name: 'practice',
|
|
6
|
+
description: 'Categorized practice questions with progress',
|
|
7
|
+
domain: 'www.nowcoder.com',
|
|
8
|
+
args: [
|
|
9
|
+
{ name: 'job', type: 'str', default: '11226', help: 'Career ID (11226=Software, 11227=Hardware, 11229=Product, 11230=Finance)' },
|
|
10
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of items' },
|
|
11
|
+
],
|
|
12
|
+
columns: ['category', 'subject', 'total', 'done', 'remaining'],
|
|
13
|
+
pipeline: [
|
|
14
|
+
{ navigate: 'https://www.nowcoder.com' },
|
|
15
|
+
{ evaluate: `(async () => {
|
|
16
|
+
const jobId = \${{ args.job | json }};
|
|
17
|
+
const limit = \${{ args.limit }};
|
|
18
|
+
const r = await fetch('https://gw-c.nowcoder.com/api/sparta/intelligent/getPCIntelligentList?jobId=' + jobId, {credentials: 'include'});
|
|
19
|
+
const d = await r.json();
|
|
20
|
+
if (!d.success) throw new Error(d.msg || 'API failed');
|
|
21
|
+
const all = [];
|
|
22
|
+
for (const tag of (d.data?.tags || [])) {
|
|
23
|
+
for (const item of (tag.items || [])) {
|
|
24
|
+
all.push({
|
|
25
|
+
category: tag.title || 'recommended',
|
|
26
|
+
subject: item.title,
|
|
27
|
+
total: item.tcount,
|
|
28
|
+
done: item.rcount,
|
|
29
|
+
remaining: item.leftCount,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return all.slice(0, limit);
|
|
34
|
+
})()
|
|
35
|
+
` },
|
|
36
|
+
],
|
|
37
|
+
});
|