@sovovs/bycli 2.1.60 → 2.1.62
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.zh-CN.md +13 -0
- package/cli-manifest.json +366 -0
- package/clis/iwhalecloud/browser-download.js +83 -0
- package/clis/iwhalecloud/client.js +97 -0
- package/clis/iwhalecloud/download.js +77 -0
- package/clis/iwhalecloud/list.js +59 -0
- package/clis/iwhalecloud/read.js +25 -0
- package/clis/zhihu/answer-waiting-draft.js +127 -0
- package/clis/zhihu/draft-shared.js +147 -0
- package/clis/zhihu/waiting.js +100 -0
- package/package.json +1 -1
- package/scripts/recorder.sh +0 -186
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cli } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
3
|
+
import { metadata, SORT_FIELDS, integer, folderId, connect, request, mailRow, listColumns } from './client.js';
|
|
4
|
+
|
|
5
|
+
export async function runList(page, args) {
|
|
6
|
+
const limit = integer(args.limit ?? 20, 'limit', 1, 10000);
|
|
7
|
+
let offset = integer(args.offset ?? 0, 'offset', 0, 2147483647);
|
|
8
|
+
const sort = String(args.sort ?? 'default');
|
|
9
|
+
const order = String(args.order ?? 'default');
|
|
10
|
+
if (sort !== 'default' && !Object.hasOwn(SORT_FIELDS, sort)) throw new ArgumentError(`Unknown sort: ${sort}`);
|
|
11
|
+
if (!['default', 'asc', 'desc'].includes(order)) throw new ArgumentError('order must be default, asc or desc');
|
|
12
|
+
if (sort === 'default' && order !== 'default') throw new ArgumentError('Specify --sort to use --order; default preserves server ordering');
|
|
13
|
+
const folder = folderId(args);
|
|
14
|
+
await connect(page);
|
|
15
|
+
const rows = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
while (rows.length < limit) {
|
|
18
|
+
// Split the validated total into server pages; do not cap the caller's total.
|
|
19
|
+
const remaining = limit - rows.length;
|
|
20
|
+
const batchSize = remaining > 100 ? 100 : remaining;
|
|
21
|
+
const body = { __type: 'FindItemRequest:#Exchange',
|
|
22
|
+
ItemShape: { __type: 'ItemResponseShape:#Exchange', BaseShape: 'AllProperties' },
|
|
23
|
+
ParentFolderIds: [folder], Traversal: 'Shallow',
|
|
24
|
+
Paging: { __type: 'IndexedPageView:#Exchange', BasePoint: 'Beginning', Offset: offset, MaxEntriesReturned: batchSize } };
|
|
25
|
+
if (sort !== 'default') {
|
|
26
|
+
const ascending = order === 'asc' || (order === 'default' && ['from', 'to', 'subject'].includes(sort));
|
|
27
|
+
body.SortOrder = [{ __type: 'SortResults:#Exchange', Order: ascending ? 'Ascending' : 'Descending',
|
|
28
|
+
Path: { __type: 'PropertyUri:#Exchange', FieldURI: SORT_FIELDS[sort] } }];
|
|
29
|
+
if (sort !== 'date') body.SortOrder.push({ __type: 'SortResults:#Exchange', Order: 'Descending',
|
|
30
|
+
Path: { __type: 'PropertyUri:#Exchange', FieldURI: 'DateTimeReceived' } });
|
|
31
|
+
}
|
|
32
|
+
const message = await request(page, 'FindItem', body);
|
|
33
|
+
const root = message.RootFolder;
|
|
34
|
+
if (!root || !Array.isArray(root.Items)) throw new CommandExecutionError('OWA FindItem returned invalid items');
|
|
35
|
+
for (const item of root.Items) {
|
|
36
|
+
const row = mailRow(item);
|
|
37
|
+
if (seen.has(row.emailId)) throw new CommandExecutionError('Mailbox changed while paging; repeat list to avoid incomplete results');
|
|
38
|
+
seen.add(row.emailId); rows.push(row);
|
|
39
|
+
if (rows.length === limit) break;
|
|
40
|
+
}
|
|
41
|
+
if (root.IncludesLastItemInRange === true || rows.length >= limit) break;
|
|
42
|
+
const next = root.IndexedPagingOffset;
|
|
43
|
+
if (!root.Items.length || !Number.isSafeInteger(next) || next <= offset) throw new CommandExecutionError('OWA pagination did not advance');
|
|
44
|
+
offset = next;
|
|
45
|
+
}
|
|
46
|
+
if (!rows.length) throw new EmptyResultError('iwhalecloud list', 'No mail at this folder/offset');
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cli({ ...metadata, name: 'list', description: '浩鲸邮箱邮件列表:服务端分页、默认排序及 9 种字段排序',
|
|
51
|
+
example: 'bycli iwhalecloud list --sort date --order desc --limit 50 -f yaml',
|
|
52
|
+
args: [
|
|
53
|
+
{ name: 'folder', default: 'inbox', choices: ['inbox', 'sent', 'drafts', 'trash', 'spam'], help: '邮件文件夹' },
|
|
54
|
+
{ name: 'folder-id', help: '自定义文件夹的 OWA FolderId,优先于 --folder' },
|
|
55
|
+
{ name: 'limit', type: 'int', default: 20, help: '返回数量,1–10000;自动分页' },
|
|
56
|
+
{ name: 'offset', type: 'int', default: 0, help: '服务端起始偏移,0 表示第一封' },
|
|
57
|
+
{ name: 'sort', default: 'default', choices: ['default', ...Object.keys(SORT_FIELDS)], help: 'default 保留服务端默认顺序' },
|
|
58
|
+
{ name: 'order', default: 'default', choices: ['default', 'asc', 'desc'], help: '默认:姓名/主题升序,其余降序;显式方向须指定 --sort' },
|
|
59
|
+
], columns: listColumns, func: runList });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
3
|
+
import { metadata, idArg, requiredId, connect, getMail, mailRow, listColumns, attachmentsOf } from './client.js';
|
|
4
|
+
|
|
5
|
+
export async function runRead(page, args) {
|
|
6
|
+
const id = requiredId(args.emailId);
|
|
7
|
+
const bodyType = String(args['body-type'] ?? 'text');
|
|
8
|
+
if (!['text', 'html'].includes(bodyType)) throw new ArgumentError('body-type must be text or html');
|
|
9
|
+
await connect(page);
|
|
10
|
+
const item = await getMail(page, id, bodyType);
|
|
11
|
+
if (typeof item.Body?.Value !== 'string' || item.Body.IsTruncated === true) {
|
|
12
|
+
throw new CommandExecutionError('OWA did not return a complete mail body', 'Open the mail in Outlook to check content restrictions');
|
|
13
|
+
}
|
|
14
|
+
const recipients = entries => (entries ?? []).map(entry => {
|
|
15
|
+
const mailbox = entry.Mailbox ?? entry;
|
|
16
|
+
return { name: mailbox.Name ?? '', email: mailbox.EmailAddress ?? '' };
|
|
17
|
+
});
|
|
18
|
+
return [{ ...mailRow(item), to: recipients(item.ToRecipients), cc: recipients(item.CcRecipients), bcc: recipients(item.BccRecipients),
|
|
19
|
+
recipientCounts: item.RecipientCounts ?? {}, bodyType: item.Body?.BodyType ?? bodyType, body: item.Body?.Value ?? '',
|
|
20
|
+
attachments: attachmentsOf(item) }];
|
|
21
|
+
}
|
|
22
|
+
cli({ ...metadata, name: 'read', description: '读取浩鲸邮件完整正文、收件人和附件列表(不标记已读)',
|
|
23
|
+
example: 'bycli iwhalecloud read "<emailId>" -f yaml',
|
|
24
|
+
args: [idArg, { name: 'body-type', default: 'text', choices: ['text', 'html'], help: '正文格式' }],
|
|
25
|
+
columns: [...listColumns, 'to', 'cc', 'bcc', 'recipientCounts', 'bodyType', 'body', 'attachments'], func: runRead });
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AuthRequiredError, CliError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
2
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
|
+
import { resolvePayload } from './write-shared.js';
|
|
4
|
+
import { resolveDraftOptions, normalizeDraftText, planDraftContent, inspectDraftPage, clickDraftControl, prepareDraftInsertion, draftScript } from './draft-shared.js';
|
|
5
|
+
|
|
6
|
+
async function inspect(page, options) {
|
|
7
|
+
const state = await page.evaluate(draftScript(inspectDraftPage, options.questionId));
|
|
8
|
+
if (state?.status === 'auth') throw new AuthRequiredError('www.zhihu.com', 'Log into Zhihu before saving an answer draft');
|
|
9
|
+
if (state?.status === 'wrong_target' || (options.answerId && state?.answerId && state.answerId !== options.answerId)) {
|
|
10
|
+
throw new CliError('TARGET_MISMATCH', 'Browser is not on the requested Zhihu question or answer');
|
|
11
|
+
}
|
|
12
|
+
if (state?.status === 'deleted') throw new CliError('ACTION_NOT_AVAILABLE', 'Your answer is deleted; Zhihu only offers undo deletion', 'Choose another question, or manage the deleted answer manually in Zhihu');
|
|
13
|
+
if (state?.status === 'ambiguous') throw new CliError('ACTION_NOT_AVAILABLE', 'Multiple answer editors are visible');
|
|
14
|
+
if (options.expectedTitle && state?.title && state.title !== options.expectedTitle) {
|
|
15
|
+
throw new CliError('TARGET_MISMATCH', 'Question title does not match --expected-title');
|
|
16
|
+
}
|
|
17
|
+
return state;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function openEditor(page, options) {
|
|
21
|
+
const opened = new Set();
|
|
22
|
+
let previous = '';
|
|
23
|
+
for (let attempt = 0; attempt < options.timeout * 2 && Date.now() <= options.deadline; attempt++) {
|
|
24
|
+
const state = await inspect(page, options);
|
|
25
|
+
if (state?.status === 'editor' && state.title) {
|
|
26
|
+
if ((options.mode !== 'auto' && state.mode !== options.mode) || (options.answerId && state.mode !== 'edit')) {
|
|
27
|
+
throw new CliError('ACTION_NOT_AVAILABLE', `Requested mode ${options.mode} does not match the ${state.mode} answer editor`);
|
|
28
|
+
}
|
|
29
|
+
// Let the editor hydrate before reading or changing its old content.
|
|
30
|
+
const signature = JSON.stringify([state.url, state.mode, state.text, state.hasRichContent]);
|
|
31
|
+
if (signature === previous) return state;
|
|
32
|
+
previous = signature;
|
|
33
|
+
} else if (state?.ownAnswerUrl) {
|
|
34
|
+
if (options.mode === 'new') throw new CliError('ACTION_NOT_AVAILABLE', 'You already have an answer to this question; use --mode edit or auto');
|
|
35
|
+
if (!opened.has(state.ownAnswerUrl)) {
|
|
36
|
+
opened.add(state.ownAnswerUrl);
|
|
37
|
+
await page.goto(`${state.ownAnswerUrl}#write`);
|
|
38
|
+
}
|
|
39
|
+
} else if (state?.status === 'page' && !opened.has(state.url)) {
|
|
40
|
+
if (await page.evaluate(draftScript(clickDraftControl, options.questionId, 'open'))) opened.add(state.url);
|
|
41
|
+
}
|
|
42
|
+
await page.wait(0.5);
|
|
43
|
+
}
|
|
44
|
+
throw new CliError('ACTION_NOT_AVAILABLE', 'Could not open a stable Zhihu answer editor', 'Open the question in Chrome and check the answer editor, or increase --timeout');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitSaved(page, options, expected) {
|
|
48
|
+
for (let attempt = 0; attempt < options.timeout * 2 && Date.now() <= options.deadline; attempt++) {
|
|
49
|
+
const state = await inspect(page, options);
|
|
50
|
+
if (state?.status === 'editor' && normalizeDraftText(state.text) === normalizeDraftText(expected) && state.saved) return state;
|
|
51
|
+
await page.wait(0.5);
|
|
52
|
+
}
|
|
53
|
+
throw new CliError('DRAFT_SAVE_TIMEOUT', 'Could not confirm that Zhihu saved the complete draft', 'Inspect the editor before retrying; some content may already have been saved');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cli({
|
|
57
|
+
site: 'zhihu', name: 'answer-waiting-draft', access: 'write',
|
|
58
|
+
description: '保存知乎回答草稿(新回答或已有回答的编辑草稿,不发布)',
|
|
59
|
+
domain: 'www.zhihu.com', strategy: Strategy.COOKIE,
|
|
60
|
+
browser: kwargs => !resolveDraftOptions(kwargs).dryRun, navigateBefore: false,
|
|
61
|
+
validateArgs: kwargs => {
|
|
62
|
+
const options = resolveDraftOptions(kwargs);
|
|
63
|
+
if (!options.execute && !options.dryRun) throw new CliError('INVALID_INPUT', 'Use --execute to save a draft or --dry-run to preview');
|
|
64
|
+
},
|
|
65
|
+
args: [
|
|
66
|
+
{ name: 'target', positional: true, required: true, help: 'Question ID, question/own-answer URL, question:<id>, or answer:<questionId>:<answerId>' },
|
|
67
|
+
{ name: 'text', positional: true, help: 'Plain-text answer body; use either text or --file' },
|
|
68
|
+
{ name: 'file', help: 'UTF-8 text file containing the answer body' },
|
|
69
|
+
{ name: 'mode', default: 'auto', choices: ['auto', 'new', 'edit'], help: 'auto detects new/edit; new refuses an existing answer; edit requires your existing answer' },
|
|
70
|
+
{ name: 'existing', default: 'error', choices: ['error', 'replace', 'append', 'prepend'], help: 'Policy when the editor already has content; identical text is left unchanged' },
|
|
71
|
+
{ name: 'separator', default: '\n\n', help: 'Literal separator for append/prepend (default: a blank line)' },
|
|
72
|
+
{ name: 'prefix', help: 'Literal text before the supplied body (include any desired newline)' },
|
|
73
|
+
{ name: 'suffix', help: 'Literal text after the supplied body (include any desired newline)' },
|
|
74
|
+
{ name: 'expected-title', help: 'Require an exact question title before editing' },
|
|
75
|
+
{ name: 'timeout', type: 'int', default: 30, help: 'Total editor/save verification wait budget in seconds (1–120)' },
|
|
76
|
+
{ name: 'close-editor', type: 'boolean', default: true, help: 'Close editor after verified save; false leaves it open (combine with --keep-tab true)' },
|
|
77
|
+
{ name: 'dry-run', type: 'boolean', help: 'Preview resolved input locally without opening or modifying the editor' },
|
|
78
|
+
{ name: 'execute', type: 'boolean', help: 'Actually save the draft; never publishes an answer' },
|
|
79
|
+
],
|
|
80
|
+
columns: ['status', 'outcome', 'question_id', 'answer_id', 'mode', 'url', 'characters', 'verified', 'published', 'editor_open', 'message'],
|
|
81
|
+
func: async (page, kwargs) => {
|
|
82
|
+
const options = resolveDraftOptions(kwargs);
|
|
83
|
+
if (!options.execute && !options.dryRun) throw new CliError('INVALID_INPUT', 'Use --execute to save a draft or --dry-run to preview');
|
|
84
|
+
const body = await resolvePayload(kwargs);
|
|
85
|
+
const payload = normalizeDraftText(options.prefix + body + options.suffix);
|
|
86
|
+
if (!payload || payload.length > 50000) throw new CliError('INVALID_INPUT', 'Draft content must contain 1–50000 characters, including prefix and suffix');
|
|
87
|
+
if (options.dryRun) return [{ status: 'preview', outcome: 'preview', question_id: options.questionId,
|
|
88
|
+
answer_id: options.answerId, mode: options.mode, url: `${options.url}#write`, characters: [...payload].length,
|
|
89
|
+
verified: false, published: false, editor_open: false, message: 'Local input preview; existing browser content has not been inspected',
|
|
90
|
+
content: payload, existing: options.existing, separator: options.separator }];
|
|
91
|
+
if (!page || typeof page.insertText !== 'function') throw new CommandExecutionError('Browser session with native text insertion is required for zhihu answer-waiting-draft');
|
|
92
|
+
options.deadline = Date.now() + options.timeout * 1000;
|
|
93
|
+
await page.goto(`${options.url}#write`);
|
|
94
|
+
const initial = await openEditor(page, options);
|
|
95
|
+
if (initial.hasRichContent && !initial.text && options.existing === 'error') throw new CliError('DRAFT_EXISTS', 'The editor contains rich content', 'Choose --existing replace, append, or prepend');
|
|
96
|
+
const plan = planDraftContent(initial.text, payload, options.existing, options.separator, initial.hasRichContent);
|
|
97
|
+
if (plan.expected.length > 50000) throw new CliError('INVALID_INPUT', 'Combined draft content exceeds 50000 characters');
|
|
98
|
+
if (plan.changed) {
|
|
99
|
+
const prepared = await page.evaluate(draftScript(prepareDraftInsertion, options.questionId, initial.text, plan.position));
|
|
100
|
+
if (!prepared?.ok) throw new CliError('DRAFT_CONFLICT', `Could not prepare the editor: ${prepared?.reason || 'unknown state'}`, 'Inspect the draft before retrying');
|
|
101
|
+
// Native input updates Draft.js state. Do not assign innerHTML/textContent.
|
|
102
|
+
await page.insertText(plan.insertion);
|
|
103
|
+
}
|
|
104
|
+
const saved = await waitSaved(page, options, plan.expected);
|
|
105
|
+
if (saved.canSaveAndLeave) await page.evaluate(draftScript(clickDraftControl, options.questionId, 'save'));
|
|
106
|
+
// Reload from Zhihu and reopen: a local DOM update or stale save badge is
|
|
107
|
+
// insufficient evidence of a persisted draft.
|
|
108
|
+
const draftUrl = saved.url.split('#')[0];
|
|
109
|
+
await page.goto('about:blank');
|
|
110
|
+
await page.goto(`${draftUrl}#write`);
|
|
111
|
+
const restored = await openEditor(page, options);
|
|
112
|
+
if (normalizeDraftText(restored.text) !== normalizeDraftText(plan.expected) || restored.mode !== initial.mode || !restored.saved) {
|
|
113
|
+
throw new CliError('DRAFT_VERIFY_FAILED', 'Reloaded draft does not match the requested content', 'Inspect the existing draft before retrying; no answer was published');
|
|
114
|
+
}
|
|
115
|
+
let editorOpen = true;
|
|
116
|
+
if (options.closeEditor) {
|
|
117
|
+
const closed = await page.evaluate(draftScript(clickDraftControl, options.questionId, 'save'));
|
|
118
|
+
if (!closed) await page.goto(draftUrl);
|
|
119
|
+
const final = await inspect(page, options);
|
|
120
|
+
editorOpen = final?.status === 'editor';
|
|
121
|
+
}
|
|
122
|
+
return [{ status: 'success', outcome: plan.changed ? 'saved' : 'unchanged', question_id: options.questionId,
|
|
123
|
+
answer_id: restored.answerId || '', mode: restored.mode, url: `${draftUrl}#write`, characters: [...normalizeDraftText(plan.expected)].length,
|
|
124
|
+
verified: true, published: false, editor_open: editorOpen,
|
|
125
|
+
message: restored.mode === 'edit' ? 'Existing answer’s edit draft saved and verified; changes not submitted' : 'New answer draft saved and verified; answer not published' }];
|
|
126
|
+
},
|
|
127
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { CliError } from '@sovovs/bycli/errors';
|
|
2
|
+
import { parseTarget } from './target.js';
|
|
3
|
+
|
|
4
|
+
export function normalizeDraftText(text) {
|
|
5
|
+
return String(text).replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ').replace(/[\u200b\ufeff]/g, '').trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function booleanOption(value, fallback, name) {
|
|
9
|
+
if (value === undefined) return fallback;
|
|
10
|
+
if (value === true || value === 'true') return true;
|
|
11
|
+
if (value === false || value === 'false') return false;
|
|
12
|
+
throw new CliError('INVALID_INPUT', `${name} must be true or false`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveDraftOptions(kwargs) {
|
|
16
|
+
const raw = String(kwargs.target ?? '').trim();
|
|
17
|
+
const target = parseTarget(/^\d+$/.test(raw) ? `question:${raw}` : raw);
|
|
18
|
+
if (!['question', 'answer'].includes(target.kind)) {
|
|
19
|
+
throw new CliError('UNSUPPORTED_TARGET', 'answer-waiting-draft requires a question or your own answer');
|
|
20
|
+
}
|
|
21
|
+
const mode = kwargs.mode ?? 'auto';
|
|
22
|
+
const existing = kwargs.existing ?? 'error';
|
|
23
|
+
const timeout = Number(kwargs.timeout ?? 30);
|
|
24
|
+
if (!['auto', 'new', 'edit'].includes(mode)) throw new CliError('INVALID_INPUT', 'Mode must be auto, new, or edit');
|
|
25
|
+
if (!['error', 'replace', 'append', 'prepend'].includes(existing)) throw new CliError('INVALID_INPUT', 'Existing content policy must be error, replace, append, or prepend');
|
|
26
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 120) throw new CliError('INVALID_INPUT', 'Timeout must be an integer from 1 to 120 seconds');
|
|
27
|
+
if (mode === 'new' && target.kind === 'answer') throw new CliError('INVALID_INPUT', 'Mode new cannot target an existing answer');
|
|
28
|
+
const dryRun = booleanOption(kwargs['dry-run'], false, '--dry-run');
|
|
29
|
+
const execute = booleanOption(kwargs.execute, false, '--execute');
|
|
30
|
+
if (dryRun && execute) throw new CliError('INVALID_INPUT', 'Use --dry-run or --execute, not both');
|
|
31
|
+
return {
|
|
32
|
+
questionId: target.kind === 'question' ? target.id : target.questionId,
|
|
33
|
+
answerId: target.kind === 'answer' ? target.id : '',
|
|
34
|
+
url: target.url, mode, existing, timeout, dryRun, execute,
|
|
35
|
+
closeEditor: booleanOption(kwargs['close-editor'], true, '--close-editor'),
|
|
36
|
+
separator: kwargs.separator ?? '\n\n',
|
|
37
|
+
prefix: kwargs.prefix ?? '', suffix: kwargs.suffix ?? '',
|
|
38
|
+
expectedTitle: kwargs['expected-title'] ?? '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function planDraftContent(current, payload, policy, separator, hasRichContent = false) {
|
|
43
|
+
const before = normalizeDraftText(current);
|
|
44
|
+
const incoming = normalizeDraftText(payload);
|
|
45
|
+
if (before === incoming) return { changed: false, expected: before, insertion: '', position: 'replace' };
|
|
46
|
+
if ((before || hasRichContent) && policy === 'error') {
|
|
47
|
+
throw new CliError('DRAFT_EXISTS', 'The editor already contains different text', 'Use --existing replace, append, or prepend to choose how to update it');
|
|
48
|
+
}
|
|
49
|
+
if (!before) separator = '';
|
|
50
|
+
if ((before || hasRichContent) && policy === 'append') return { changed: true, expected: before + separator + incoming, insertion: separator + incoming, position: 'append' };
|
|
51
|
+
if ((before || hasRichContent) && policy === 'prepend') return { changed: true, expected: incoming + separator + before, insertion: incoming + separator, position: 'prepend' };
|
|
52
|
+
return { changed: true, expected: incoming, insertion: incoming, position: 'replace' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// These functions run in the browser. Keep them self-contained for serialization
|
|
56
|
+
// and fixture tests; only inspect the question's answer form, never comments.
|
|
57
|
+
export function inspectDraftPage(questionId) {
|
|
58
|
+
const clean = text => String(text || '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ').replace(/[\u200b\ufeff]/g, '').trim();
|
|
59
|
+
const visible = node => {
|
|
60
|
+
for (let n = node; n; n = n.parentElement) {
|
|
61
|
+
if (n.hidden || n.getAttribute('aria-hidden') === 'true') return false;
|
|
62
|
+
const style = getComputedStyle(n);
|
|
63
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
if (/^\/(signin|signup|account\/unhuman)(\/|$)/.test(location.pathname)) return { status: 'auth' };
|
|
68
|
+
const path = location.pathname.match(/^\/question\/(\d+)(?:\/answer\/(\d+))?\/?$/);
|
|
69
|
+
if (location.origin !== 'https://www.zhihu.com' || path?.[1] !== questionId) return { status: 'wrong_target' };
|
|
70
|
+
const title = clean(document.querySelector('h1')?.textContent);
|
|
71
|
+
if (Array.from(document.querySelectorAll('button')).some(b => visible(b) && clean(b.textContent) === '撤销删除')) return { status: 'deleted', title };
|
|
72
|
+
let ownAnswerUrl = '';
|
|
73
|
+
for (const link of document.querySelectorAll('a[href]')) {
|
|
74
|
+
if (clean(link.textContent) !== '查看我的回答' || !visible(link)) continue;
|
|
75
|
+
const url = new URL(link.getAttribute('href'), location.href);
|
|
76
|
+
if (url.origin === location.origin && new RegExp('^/question/' + questionId + '/answer/\\d+/?$').test(url.pathname)) {
|
|
77
|
+
ownAnswerUrl = url.origin + url.pathname;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const forms = Array.from(document.querySelectorAll('form.AnswerForm')).filter(visible);
|
|
82
|
+
const editors = forms.flatMap(form => Array.from(form.querySelectorAll('.public-DraftEditor-content[contenteditable="true"]')).filter(visible));
|
|
83
|
+
const base = { url: location.href, title, answerId: path[2] || '', ownAnswerUrl };
|
|
84
|
+
if (editors.length > 1) return { ...base, status: 'ambiguous' };
|
|
85
|
+
if (!editors.length) return { ...base, status: 'page' };
|
|
86
|
+
const editor = editors[0];
|
|
87
|
+
const form = editor.closest('form.AnswerForm');
|
|
88
|
+
const labels = Array.from(form.querySelectorAll('button')).filter(visible).map(b => clean(b.textContent));
|
|
89
|
+
const mode = labels.includes('提交修改') ? 'edit' : labels.includes('发布回答') ? 'new' : '';
|
|
90
|
+
if (!mode) return { ...base, status: 'page' };
|
|
91
|
+
const blocks = Array.from(editor.querySelectorAll('[data-block="true"]'));
|
|
92
|
+
const text = clean(blocks.length ? blocks.map(b => b.textContent).join('\n') : editor.innerText || editor.textContent);
|
|
93
|
+
const tip = form.querySelector('.DraftStatusTip');
|
|
94
|
+
const saved = !!tip && !!tip.querySelector('.ZDI--CloudCheck24') && !/保存中|失败/.test(tip.textContent);
|
|
95
|
+
return { ...base, status: 'editor', mode, text, saved,
|
|
96
|
+
saveText: clean(tip?.textContent),
|
|
97
|
+
hasRichContent: !!editor.querySelector('img, video, audio, iframe, table, pre') || blocks.some(b => b.className && !b.classList.contains('Editable-unstyled')),
|
|
98
|
+
canSaveAndLeave: labels.includes('保存草稿并离开'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clickDraftControl(questionId, action) {
|
|
103
|
+
if (location.origin !== 'https://www.zhihu.com' || !new RegExp('^/question/' + questionId + '(?:/answer/\\d+)?/?$').test(location.pathname)) return false;
|
|
104
|
+
const clean = text => String(text || '').replace(/[\u200b\ufeff]/g, '').trim();
|
|
105
|
+
const visible = node => {
|
|
106
|
+
for (let n = node; n; n = n.parentElement) {
|
|
107
|
+
if (n.hidden || n.getAttribute('aria-hidden') === 'true' || getComputedStyle(n).display === 'none' || getComputedStyle(n).visibility === 'hidden') return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
const allowed = action === 'save' ? ['保存草稿并离开'] : action === 'open' ? ['编辑回答', '写回答'] : [];
|
|
112
|
+
const scope = action === 'save' ? 'form.AnswerForm button' : 'button';
|
|
113
|
+
const button = Array.from(document.querySelectorAll(scope)).find(b => allowed.includes(clean(b.textContent)) && !b.disabled && visible(b));
|
|
114
|
+
if (!button) return false;
|
|
115
|
+
button.click();
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function prepareDraftInsertion(questionId, expectedText, position) {
|
|
120
|
+
if (location.origin !== 'https://www.zhihu.com' || !new RegExp('^/question/' + questionId + '(?:/answer/\\d+)?/?$').test(location.pathname)) return { ok: false, reason: 'wrong_target' };
|
|
121
|
+
const visible = node => {
|
|
122
|
+
for (let n = node; n; n = n.parentElement) {
|
|
123
|
+
if (n.hidden || n.getAttribute('aria-hidden') === 'true' || getComputedStyle(n).display === 'none' || getComputedStyle(n).visibility === 'hidden') return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
const editors = Array.from(document.querySelectorAll('form.AnswerForm .public-DraftEditor-content[contenteditable="true"]')).filter(visible);
|
|
128
|
+
if (editors.length !== 1) return { ok: false, reason: 'ambiguous_editor' };
|
|
129
|
+
const editor = editors[0];
|
|
130
|
+
const clean = text => String(text || '').replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ').replace(/[\u200b\ufeff]/g, '').trim();
|
|
131
|
+
const blocks = Array.from(editor.querySelectorAll('[data-block="true"]'));
|
|
132
|
+
const current = clean(blocks.length ? blocks.map(b => b.textContent).join('\n') : editor.innerText || editor.textContent);
|
|
133
|
+
if (current !== clean(expectedText)) return { ok: false, reason: 'content_changed' };
|
|
134
|
+
if (!['replace', 'append', 'prepend'].includes(position)) return { ok: false, reason: 'invalid_position' };
|
|
135
|
+
editor.focus();
|
|
136
|
+
const range = document.createRange();
|
|
137
|
+
range.selectNodeContents(editor);
|
|
138
|
+
if (position !== 'replace') range.collapse(position === 'prepend');
|
|
139
|
+
const selection = window.getSelection();
|
|
140
|
+
selection.removeAllRanges();
|
|
141
|
+
selection.addRange(range);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function draftScript(fn, ...args) {
|
|
146
|
+
return `(${fn.toString()})(${args.map(arg => JSON.stringify(arg)).join(',')})`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { AuthRequiredError, CliError } from '@sovovs/bycli/errors';
|
|
3
|
+
|
|
4
|
+
// Serialized into the browser; keep this function self-contained. Use the
|
|
5
|
+
// semantic list and question URLs rather than Zhihu's generated CSS hashes.
|
|
6
|
+
export function readWaitingPage(expectedLabel) {
|
|
7
|
+
if (/^\/(?:signin|signup|account\/unhuman)(?:\/|$)/.test(location.pathname)) {
|
|
8
|
+
return { status: 'auth', items: [] };
|
|
9
|
+
}
|
|
10
|
+
if (expectedLabel && document.querySelector('[role="tab"][aria-selected="true"]')?.textContent.trim() !== expectedLabel) {
|
|
11
|
+
return { status: 'missing', items: [] };
|
|
12
|
+
}
|
|
13
|
+
const list = document.querySelector('.QuestionWaiting-questions');
|
|
14
|
+
if (!list) return { status: 'missing', items: [] };
|
|
15
|
+
const items = [];
|
|
16
|
+
for (const link of list.querySelectorAll('a[href]')) {
|
|
17
|
+
const url = new URL(link.getAttribute('href'), location.href);
|
|
18
|
+
const id = url.pathname.match(/^\/question\/(\d+)\/?$/)?.[1];
|
|
19
|
+
if (url.origin !== 'https://www.zhihu.com' || !id) continue;
|
|
20
|
+
const titleNode = link.cloneNode(true);
|
|
21
|
+
// The title link may have a block badge such as “新问” or “飙升”.
|
|
22
|
+
titleNode.querySelectorAll('div').forEach(node => node.remove());
|
|
23
|
+
const title = titleNode.textContent.trim();
|
|
24
|
+
if (!title) continue;
|
|
25
|
+
const metadata = link.nextElementSibling?.textContent || '';
|
|
26
|
+
const count = label => {
|
|
27
|
+
const match = metadata.match(new RegExp('([\\d,]+(?:\\.\\d+)?)\\s*([万亿]?)\\s*' + label));
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const multiplier = match[2] === '万' ? 10000 : match[2] === '亿' ? 100000000 : 1;
|
|
30
|
+
return Math.round(Number(match[1].replace(/,/g, '')) * multiplier);
|
|
31
|
+
};
|
|
32
|
+
items.push({ id, title, views: count('浏览'), answers: count('回答'), followers: count('关注'),
|
|
33
|
+
url: `https://www.zhihu.com/question/${id}` });
|
|
34
|
+
}
|
|
35
|
+
return { status: 'ready', items };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CATEGORIES = {
|
|
39
|
+
recommend: { query: '', label: '为你推荐' },
|
|
40
|
+
invited: { query: '?type=invite', label: '邀请回答' },
|
|
41
|
+
latest: { query: '?type=new', label: '最新问题' },
|
|
42
|
+
hot: { query: '?type=hot', label: '人气问题' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
cli({
|
|
46
|
+
site: 'zhihu',
|
|
47
|
+
name: 'waiting',
|
|
48
|
+
access: 'read',
|
|
49
|
+
description: '知乎写回答(等你来答)问题列表',
|
|
50
|
+
domain: 'www.zhihu.com',
|
|
51
|
+
strategy: Strategy.COOKIE,
|
|
52
|
+
args: [
|
|
53
|
+
{ name: 'type', default: 'recommend', choices: Object.keys(CATEGORIES), help: 'Question category: recommend 为你推荐, invited 邀请回答, latest 最新问题, hot 人气问题' },
|
|
54
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum questions to return (1–1000; stops when the page stops loading)' },
|
|
55
|
+
],
|
|
56
|
+
columns: ['rank', 'id', 'title', 'views', 'answers', 'followers', 'url'],
|
|
57
|
+
func: async (page, kwargs) => {
|
|
58
|
+
const limit = Number(kwargs.limit ?? 20);
|
|
59
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
|
|
60
|
+
throw new CliError('INVALID_INPUT', 'Limit must be a positive integer no greater than 1000', 'Example: bycli zhihu waiting --limit 20');
|
|
61
|
+
}
|
|
62
|
+
const type = kwargs.type ?? 'recommend';
|
|
63
|
+
if (!Object.hasOwn(CATEGORIES, type)) {
|
|
64
|
+
throw new CliError('INVALID_INPUT', 'Type must be one of: recommend, invited, latest, hot', 'Example: bycli zhihu waiting --type latest');
|
|
65
|
+
}
|
|
66
|
+
const category = CATEGORIES[type];
|
|
67
|
+
await page.goto(`https://www.zhihu.com/question/waiting${category.query}`);
|
|
68
|
+
const rows = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
let idle = 0;
|
|
71
|
+
let missing = 0;
|
|
72
|
+
// Bound both stalled loading and a feed that continually changes.
|
|
73
|
+
for (let round = 0; round < 120; round++) {
|
|
74
|
+
const state = await page.evaluate(`(${readWaitingPage.toString()})(${JSON.stringify(category.label)})`);
|
|
75
|
+
if (state?.status === 'auth') {
|
|
76
|
+
throw new AuthRequiredError('www.zhihu.com', 'Log into Zhihu and open https://www.zhihu.com/question/waiting');
|
|
77
|
+
}
|
|
78
|
+
if (state?.status !== 'ready' || !Array.isArray(state.items)) {
|
|
79
|
+
if (++missing >= 5) {
|
|
80
|
+
throw new CliError('DOM_CHANGED', 'Zhihu waiting question list was not found', 'Open https://www.zhihu.com/question/waiting and check that the question list is visible');
|
|
81
|
+
}
|
|
82
|
+
await page.wait(1);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
missing = 0;
|
|
86
|
+
const before = rows.length;
|
|
87
|
+
for (const item of state.items) {
|
|
88
|
+
if (seen.has(item.id)) continue;
|
|
89
|
+
seen.add(item.id);
|
|
90
|
+
rows.push({ rank: rows.length + 1, ...item });
|
|
91
|
+
if (rows.length >= limit) return rows;
|
|
92
|
+
}
|
|
93
|
+
idle = rows.length === before ? idle + 1 : 0;
|
|
94
|
+
if (idle >= 3) break;
|
|
95
|
+
await page.scroll('down', 10000);
|
|
96
|
+
await page.wait(1.5);
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
},
|
|
100
|
+
});
|