@sovovs/bycli 2.1.61 → 2.1.63
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/cli-manifest.json +168 -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/extension/README.md +19 -0
- package/extension/dist/background.js +178 -13
- package/extension/manifest.json +2 -1
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -34388,6 +34388,130 @@
|
|
|
34388
34388
|
"sourceFile": "zhihu/answer-detail.js",
|
|
34389
34389
|
"navigateBefore": "https://www.zhihu.com"
|
|
34390
34390
|
},
|
|
34391
|
+
{
|
|
34392
|
+
"site": "zhihu",
|
|
34393
|
+
"name": "answer-waiting-draft",
|
|
34394
|
+
"description": "保存知乎回答草稿(新回答或已有回答的编辑草稿,不发布)",
|
|
34395
|
+
"access": "write",
|
|
34396
|
+
"domain": "www.zhihu.com",
|
|
34397
|
+
"strategy": "cookie",
|
|
34398
|
+
"browser": "conditional",
|
|
34399
|
+
"args": [
|
|
34400
|
+
{
|
|
34401
|
+
"name": "target",
|
|
34402
|
+
"type": "str",
|
|
34403
|
+
"required": true,
|
|
34404
|
+
"positional": true,
|
|
34405
|
+
"help": "Question ID, question/own-answer URL, question:<id>, or answer:<questionId>:<answerId>"
|
|
34406
|
+
},
|
|
34407
|
+
{
|
|
34408
|
+
"name": "text",
|
|
34409
|
+
"type": "str",
|
|
34410
|
+
"required": false,
|
|
34411
|
+
"positional": true,
|
|
34412
|
+
"help": "Plain-text answer body; use either text or --file"
|
|
34413
|
+
},
|
|
34414
|
+
{
|
|
34415
|
+
"name": "file",
|
|
34416
|
+
"type": "str",
|
|
34417
|
+
"required": false,
|
|
34418
|
+
"help": "UTF-8 text file containing the answer body"
|
|
34419
|
+
},
|
|
34420
|
+
{
|
|
34421
|
+
"name": "mode",
|
|
34422
|
+
"type": "str",
|
|
34423
|
+
"default": "auto",
|
|
34424
|
+
"required": false,
|
|
34425
|
+
"help": "auto detects new/edit; new refuses an existing answer; edit requires your existing answer",
|
|
34426
|
+
"choices": [
|
|
34427
|
+
"auto",
|
|
34428
|
+
"new",
|
|
34429
|
+
"edit"
|
|
34430
|
+
]
|
|
34431
|
+
},
|
|
34432
|
+
{
|
|
34433
|
+
"name": "existing",
|
|
34434
|
+
"type": "str",
|
|
34435
|
+
"default": "error",
|
|
34436
|
+
"required": false,
|
|
34437
|
+
"help": "Policy when the editor already has content; identical text is left unchanged",
|
|
34438
|
+
"choices": [
|
|
34439
|
+
"error",
|
|
34440
|
+
"replace",
|
|
34441
|
+
"append",
|
|
34442
|
+
"prepend"
|
|
34443
|
+
]
|
|
34444
|
+
},
|
|
34445
|
+
{
|
|
34446
|
+
"name": "separator",
|
|
34447
|
+
"type": "str",
|
|
34448
|
+
"default": "\n\n",
|
|
34449
|
+
"required": false,
|
|
34450
|
+
"help": "Literal separator for append/prepend (default: a blank line)"
|
|
34451
|
+
},
|
|
34452
|
+
{
|
|
34453
|
+
"name": "prefix",
|
|
34454
|
+
"type": "str",
|
|
34455
|
+
"required": false,
|
|
34456
|
+
"help": "Literal text before the supplied body (include any desired newline)"
|
|
34457
|
+
},
|
|
34458
|
+
{
|
|
34459
|
+
"name": "suffix",
|
|
34460
|
+
"type": "str",
|
|
34461
|
+
"required": false,
|
|
34462
|
+
"help": "Literal text after the supplied body (include any desired newline)"
|
|
34463
|
+
},
|
|
34464
|
+
{
|
|
34465
|
+
"name": "expected-title",
|
|
34466
|
+
"type": "str",
|
|
34467
|
+
"required": false,
|
|
34468
|
+
"help": "Require an exact question title before editing"
|
|
34469
|
+
},
|
|
34470
|
+
{
|
|
34471
|
+
"name": "timeout",
|
|
34472
|
+
"type": "int",
|
|
34473
|
+
"default": 30,
|
|
34474
|
+
"required": false,
|
|
34475
|
+
"help": "Total editor/save verification wait budget in seconds (1–120)"
|
|
34476
|
+
},
|
|
34477
|
+
{
|
|
34478
|
+
"name": "close-editor",
|
|
34479
|
+
"type": "boolean",
|
|
34480
|
+
"default": true,
|
|
34481
|
+
"required": false,
|
|
34482
|
+
"help": "Close editor after verified save; false leaves it open (combine with --keep-tab true)"
|
|
34483
|
+
},
|
|
34484
|
+
{
|
|
34485
|
+
"name": "dry-run",
|
|
34486
|
+
"type": "boolean",
|
|
34487
|
+
"required": false,
|
|
34488
|
+
"help": "Preview resolved input locally without opening or modifying the editor"
|
|
34489
|
+
},
|
|
34490
|
+
{
|
|
34491
|
+
"name": "execute",
|
|
34492
|
+
"type": "boolean",
|
|
34493
|
+
"required": false,
|
|
34494
|
+
"help": "Actually save the draft; never publishes an answer"
|
|
34495
|
+
}
|
|
34496
|
+
],
|
|
34497
|
+
"columns": [
|
|
34498
|
+
"status",
|
|
34499
|
+
"outcome",
|
|
34500
|
+
"question_id",
|
|
34501
|
+
"answer_id",
|
|
34502
|
+
"mode",
|
|
34503
|
+
"url",
|
|
34504
|
+
"characters",
|
|
34505
|
+
"verified",
|
|
34506
|
+
"published",
|
|
34507
|
+
"editor_open",
|
|
34508
|
+
"message"
|
|
34509
|
+
],
|
|
34510
|
+
"type": "js",
|
|
34511
|
+
"modulePath": "zhihu/answer-waiting-draft.js",
|
|
34512
|
+
"sourceFile": "zhihu/answer-waiting-draft.js",
|
|
34513
|
+
"navigateBefore": false
|
|
34514
|
+
},
|
|
34391
34515
|
{
|
|
34392
34516
|
"site": "zhihu",
|
|
34393
34517
|
"name": "collection",
|
|
@@ -34829,6 +34953,50 @@
|
|
|
34829
34953
|
"sourceFile": "zhihu/search.js",
|
|
34830
34954
|
"navigateBefore": "https://www.zhihu.com"
|
|
34831
34955
|
},
|
|
34956
|
+
{
|
|
34957
|
+
"site": "zhihu",
|
|
34958
|
+
"name": "waiting",
|
|
34959
|
+
"description": "知乎写回答(等你来答)问题列表",
|
|
34960
|
+
"access": "read",
|
|
34961
|
+
"domain": "www.zhihu.com",
|
|
34962
|
+
"strategy": "cookie",
|
|
34963
|
+
"browser": true,
|
|
34964
|
+
"args": [
|
|
34965
|
+
{
|
|
34966
|
+
"name": "type",
|
|
34967
|
+
"type": "str",
|
|
34968
|
+
"default": "recommend",
|
|
34969
|
+
"required": false,
|
|
34970
|
+
"help": "Question category: recommend 为你推荐, invited 邀请回答, latest 最新问题, hot 人气问题",
|
|
34971
|
+
"choices": [
|
|
34972
|
+
"recommend",
|
|
34973
|
+
"invited",
|
|
34974
|
+
"latest",
|
|
34975
|
+
"hot"
|
|
34976
|
+
]
|
|
34977
|
+
},
|
|
34978
|
+
{
|
|
34979
|
+
"name": "limit",
|
|
34980
|
+
"type": "int",
|
|
34981
|
+
"default": 20,
|
|
34982
|
+
"required": false,
|
|
34983
|
+
"help": "Maximum questions to return (1–1000; stops when the page stops loading)"
|
|
34984
|
+
}
|
|
34985
|
+
],
|
|
34986
|
+
"columns": [
|
|
34987
|
+
"rank",
|
|
34988
|
+
"id",
|
|
34989
|
+
"title",
|
|
34990
|
+
"views",
|
|
34991
|
+
"answers",
|
|
34992
|
+
"followers",
|
|
34993
|
+
"url"
|
|
34994
|
+
],
|
|
34995
|
+
"type": "js",
|
|
34996
|
+
"modulePath": "zhihu/waiting.js",
|
|
34997
|
+
"sourceFile": "zhihu/waiting.js",
|
|
34998
|
+
"navigateBefore": "https://www.zhihu.com"
|
|
34999
|
+
},
|
|
34832
35000
|
{
|
|
34833
35001
|
"site": "zlibrary",
|
|
34834
35002
|
"name": "info",
|
|
@@ -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
|
+
});
|
package/extension/README.md
CHANGED
|
@@ -9,6 +9,9 @@ commands.
|
|
|
9
9
|
- `debugger`: sends CDP commands to byCLI-controlled or bound tabs.
|
|
10
10
|
- `tabs` / `tabGroups`: manages the dedicated byCLI automation container and
|
|
11
11
|
reports selected tab metadata back to the CLI.
|
|
12
|
+
- `system.display`: reads logical display work areas so owned windows fit the
|
|
13
|
+
usable screen space, including headless virtual displays. It does not change
|
|
14
|
+
display settings.
|
|
12
15
|
- `cookies`: reads cookies for browser-backed adapters that need authenticated
|
|
13
16
|
fetches.
|
|
14
17
|
- `downloads`: surfaces download lifecycle to `bycli browser wait download`.
|
|
@@ -17,6 +20,22 @@ commands.
|
|
|
17
20
|
filters by the command's filename/URL pattern and timeout, and does not modify,
|
|
18
21
|
redirect, or persist browser download history.
|
|
19
22
|
|
|
23
|
+
## Owned Window Placement
|
|
24
|
+
|
|
25
|
+
New byCLI windows use up to 1280 × 900 logical pixels, capped by the selected
|
|
26
|
+
display's work area. The last-focused normal Chrome window selects the display;
|
|
27
|
+
the primary display is the fallback. Existing owned normal windows stay on the
|
|
28
|
+
display with their largest intersection, keeping smaller sizes and positions
|
|
29
|
+
that already fit. Bounds are corrected on reuse, lease restoration and display
|
|
30
|
+
changes without changing focus. Borrowed user windows and minimized, maximized
|
|
31
|
+
or fullscreen window states are preserved.
|
|
32
|
+
Dragging an owned tab into a user window does not transfer window ownership
|
|
33
|
+
during lease restoration. A move, resize or state change while display metadata
|
|
34
|
+
is being read also takes precedence over that pending correction.
|
|
35
|
+
|
|
36
|
+
If display metadata is unavailable, byCLI logs a warning. New windows use
|
|
37
|
+
Chrome's natural size and placement, and existing windows retain their bounds.
|
|
38
|
+
|
|
20
39
|
Suggested Chrome Web Store justification for `downloads`:
|
|
21
40
|
|
|
22
41
|
> This extension uses `chrome.downloads` to surface download lifecycle
|
|
@@ -1558,6 +1558,46 @@ function checkUrlSyntax(input, opts = {}) {
|
|
|
1558
1558
|
return { ok: true, url: parsed.toString(), hostname, isIpLiteral: false };
|
|
1559
1559
|
}
|
|
1560
1560
|
|
|
1561
|
+
function hasUsableBounds(bounds) {
|
|
1562
|
+
return !!bounds && Number.isFinite(bounds.left) && Number.isFinite(bounds.top) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width >= 1 && bounds.height >= 1;
|
|
1563
|
+
}
|
|
1564
|
+
function selectWindowWorkArea(displays, reference) {
|
|
1565
|
+
const usable = displays.filter((display) => display.isEnabled && display.activeState !== "inactive" && hasUsableBounds(display.workArea));
|
|
1566
|
+
if (usable.length === 0) return null;
|
|
1567
|
+
let selected = usable.find((display) => display.isPrimary) ?? usable[0];
|
|
1568
|
+
let largestIntersection = 0;
|
|
1569
|
+
if (hasUsableBounds(reference)) {
|
|
1570
|
+
for (const display of usable) {
|
|
1571
|
+
const area = display.workArea;
|
|
1572
|
+
const width = Math.max(0, Math.min(reference.left + reference.width, area.left + area.width) - Math.max(reference.left, area.left));
|
|
1573
|
+
const height = Math.max(0, Math.min(reference.top + reference.height, area.top + area.height) - Math.max(reference.top, area.top));
|
|
1574
|
+
const intersection = width * height;
|
|
1575
|
+
if (intersection > largestIntersection) {
|
|
1576
|
+
largestIntersection = intersection;
|
|
1577
|
+
selected = display;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return selected.workArea;
|
|
1582
|
+
}
|
|
1583
|
+
function createOwnedWindowBounds(area) {
|
|
1584
|
+
return {
|
|
1585
|
+
left: area.left,
|
|
1586
|
+
top: area.top,
|
|
1587
|
+
width: Math.min(1280, area.width),
|
|
1588
|
+
height: Math.min(900, area.height)
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function constrainOwnedWindowBounds(window, area) {
|
|
1592
|
+
if (!hasUsableBounds(window)) return null;
|
|
1593
|
+
const width = Math.min(window.width, area.width);
|
|
1594
|
+
const height = Math.min(window.height, area.height);
|
|
1595
|
+
const left = Math.min(Math.max(window.left, area.left), area.left + area.width - width);
|
|
1596
|
+
const top = Math.min(Math.max(window.top, area.top), area.top + area.height - height);
|
|
1597
|
+
if (width === window.width && height === window.height && left === window.left && top === window.top) return null;
|
|
1598
|
+
return { left, top, width, height };
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1561
1601
|
const EXTENSION_CAPABILITIES = ["focus-window-v1", "ima-reader-v1"];
|
|
1562
1602
|
let ws = null;
|
|
1563
1603
|
let reconnectTimer = null;
|
|
@@ -2047,7 +2087,86 @@ async function focusOwnedWindowIfRequested(windowId, mode) {
|
|
|
2047
2087
|
if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => {
|
|
2048
2088
|
});
|
|
2049
2089
|
}
|
|
2090
|
+
async function getOwnedWindowWorkArea(reference) {
|
|
2091
|
+
try {
|
|
2092
|
+
if (typeof chrome.system?.display?.getInfo !== "function") throw new Error("system.display API unavailable");
|
|
2093
|
+
const displays = await chrome.system.display.getInfo();
|
|
2094
|
+
const area = selectWindowWorkArea(displays, reference);
|
|
2095
|
+
if (!area) throw new Error("no enabled displays with a valid work area");
|
|
2096
|
+
return area;
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
console.warn(`[bycli] Unable to query display work area: ${err instanceof Error ? err.message : String(err)}`);
|
|
2099
|
+
return null;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
async function getNewOwnedWindowBounds() {
|
|
2103
|
+
let reference;
|
|
2104
|
+
try {
|
|
2105
|
+
if (typeof chrome.windows.getLastFocused === "function") {
|
|
2106
|
+
const lastFocused = await chrome.windows.getLastFocused({ windowTypes: ["normal"] });
|
|
2107
|
+
if (lastFocused.type === "normal") reference = lastFocused;
|
|
2108
|
+
}
|
|
2109
|
+
} catch {
|
|
2110
|
+
}
|
|
2111
|
+
const area = await getOwnedWindowWorkArea(reference);
|
|
2112
|
+
return area ? createOwnedWindowBounds(area) : void 0;
|
|
2113
|
+
}
|
|
2114
|
+
const ownedWindowGeometryUpdates = /* @__PURE__ */ new Map();
|
|
2115
|
+
const excludedOwnedWindowIds = /* @__PURE__ */ new Set();
|
|
2116
|
+
function isKnownOwnedWindow(windowId) {
|
|
2117
|
+
return Object.values(ownedContainers).some((container) => container.windowId === windowId) || [...automationSessions.values()].some((session) => session.owned && session.windowId === windowId);
|
|
2118
|
+
}
|
|
2119
|
+
function rememberOwnedTabDrift(tab, ownedWindowId) {
|
|
2120
|
+
if (tab.windowId !== ownedWindowId && !isKnownOwnedWindow(tab.windowId)) {
|
|
2121
|
+
excludedOwnedWindowIds.add(tab.windowId);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
async function rememberOwnedGroupDrift(groupId, ownedWindowId) {
|
|
2125
|
+
if (groupId === null) return;
|
|
2126
|
+
try {
|
|
2127
|
+
const group = await chrome.tabGroups.get(groupId);
|
|
2128
|
+
if (group.windowId !== ownedWindowId && !isKnownOwnedWindow(group.windowId)) {
|
|
2129
|
+
excludedOwnedWindowIds.add(group.windowId);
|
|
2130
|
+
}
|
|
2131
|
+
} catch {
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
async function constrainOwnedWindowGeometry(windowId) {
|
|
2135
|
+
const previous = ownedWindowGeometryUpdates.get(windowId) ?? Promise.resolve();
|
|
2136
|
+
const next = previous.catch(() => void 0).then(async () => {
|
|
2137
|
+
if (!isKnownOwnedWindow(windowId)) return;
|
|
2138
|
+
try {
|
|
2139
|
+
const window = await chrome.windows.get(windowId);
|
|
2140
|
+
if (window.type !== "normal" || window.state !== "normal") return;
|
|
2141
|
+
const originalBounds = { left: window.left, top: window.top, width: window.width, height: window.height };
|
|
2142
|
+
const area = await getOwnedWindowWorkArea(window);
|
|
2143
|
+
if (!area || !isKnownOwnedWindow(windowId)) return;
|
|
2144
|
+
const current = await chrome.windows.get(windowId);
|
|
2145
|
+
if (!isKnownOwnedWindow(windowId) || current.type !== "normal" || current.state !== "normal" || current.left !== originalBounds.left || current.top !== originalBounds.top || current.width !== originalBounds.width || current.height !== originalBounds.height) return;
|
|
2146
|
+
const bounds = constrainOwnedWindowBounds(current, area);
|
|
2147
|
+
if (bounds) await chrome.windows.update(windowId, bounds);
|
|
2148
|
+
} catch (err) {
|
|
2149
|
+
console.warn(`[bycli] Failed to constrain owned window ${windowId} geometry: ${err instanceof Error ? err.message : String(err)}`);
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
const tracked = next.finally(() => {
|
|
2153
|
+
if (ownedWindowGeometryUpdates.get(windowId) === tracked) ownedWindowGeometryUpdates.delete(windowId);
|
|
2154
|
+
});
|
|
2155
|
+
ownedWindowGeometryUpdates.set(windowId, tracked);
|
|
2156
|
+
await tracked;
|
|
2157
|
+
}
|
|
2158
|
+
chrome.system?.display?.onDisplayChanged?.addListener(async () => {
|
|
2159
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2160
|
+
for (const container of Object.values(ownedContainers)) {
|
|
2161
|
+
if (container.windowId !== null) ids.add(container.windowId);
|
|
2162
|
+
}
|
|
2163
|
+
for (const session of automationSessions.values()) {
|
|
2164
|
+
if (session.owned) ids.add(session.windowId);
|
|
2165
|
+
}
|
|
2166
|
+
await Promise.all([...ids].map(constrainOwnedWindowGeometry));
|
|
2167
|
+
});
|
|
2050
2168
|
async function toOwnedContainerDiscoveryCandidate(group) {
|
|
2169
|
+
if (excludedOwnedWindowIds.has(group.windowId)) return null;
|
|
2051
2170
|
try {
|
|
2052
2171
|
const chromeWindow = await chrome.windows.get(group.windowId);
|
|
2053
2172
|
const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id);
|
|
@@ -2074,6 +2193,7 @@ async function discoverOwnedContainerFromTabGroup(role) {
|
|
|
2074
2193
|
if (container.groupId !== null) {
|
|
2075
2194
|
try {
|
|
2076
2195
|
const group = await chrome.tabGroups.get(container.groupId);
|
|
2196
|
+
if (excludedOwnedWindowIds.has(group.windowId)) throw new Error("Owned group moved to a user window");
|
|
2077
2197
|
await chrome.windows.get(group.windowId);
|
|
2078
2198
|
container.windowId = group.windowId;
|
|
2079
2199
|
return { windowId: group.windowId, groupId: group.id };
|
|
@@ -2139,8 +2259,11 @@ async function ensureOwnedContainerWindow(role, initialUrl, mode = "background")
|
|
|
2139
2259
|
async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "background") {
|
|
2140
2260
|
const container = ownedContainers[role];
|
|
2141
2261
|
if (container.windowId !== null) {
|
|
2262
|
+
const knownWindowId = container.windowId;
|
|
2263
|
+
const knownGroupId = container.groupId;
|
|
2142
2264
|
try {
|
|
2143
2265
|
await chrome.windows.get(container.windowId);
|
|
2266
|
+
await constrainOwnedWindowGeometry(container.windowId);
|
|
2144
2267
|
await focusOwnedWindowIfRequested(container.windowId, mode);
|
|
2145
2268
|
const initialTabId2 = await findReusableOwnedContainerTab(container.windowId, await getOwnedContainerGroupId(role, container.windowId));
|
|
2146
2269
|
await ensureOwnedContainerTabGroup(role, container.windowId, [initialTabId2]);
|
|
@@ -2149,12 +2272,16 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back
|
|
|
2149
2272
|
initialTabId: initialTabId2
|
|
2150
2273
|
};
|
|
2151
2274
|
} catch {
|
|
2152
|
-
|
|
2153
|
-
container.groupId
|
|
2275
|
+
await rememberOwnedGroupDrift(knownGroupId, knownWindowId);
|
|
2276
|
+
if (container.windowId === knownWindowId && container.groupId === knownGroupId) {
|
|
2277
|
+
container.windowId = null;
|
|
2278
|
+
container.groupId = null;
|
|
2279
|
+
}
|
|
2154
2280
|
}
|
|
2155
2281
|
}
|
|
2156
2282
|
const discovered = await discoverOwnedContainerFromTabGroup(role);
|
|
2157
2283
|
if (discovered) {
|
|
2284
|
+
await constrainOwnedWindowGeometry(discovered.windowId);
|
|
2158
2285
|
await focusOwnedWindowIfRequested(discovered.windowId, mode);
|
|
2159
2286
|
const initialTabId2 = await findReusableOwnedContainerTab(discovered.windowId, discovered.groupId);
|
|
2160
2287
|
await ensureOwnedContainerTabGroup(role, discovered.windowId, [initialTabId2]);
|
|
@@ -2168,8 +2295,7 @@ async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "back
|
|
|
2168
2295
|
const win = await chrome.windows.create({
|
|
2169
2296
|
url: startUrl,
|
|
2170
2297
|
focused: mode === "foreground",
|
|
2171
|
-
|
|
2172
|
-
height: 900,
|
|
2298
|
+
...await getNewOwnedWindowBounds(),
|
|
2173
2299
|
type: "normal"
|
|
2174
2300
|
});
|
|
2175
2301
|
container.windowId = win.id;
|
|
@@ -2264,9 +2390,14 @@ async function getAutomationWindow(leaseKey, initialUrl) {
|
|
|
2264
2390
|
const tabId = existing.preferredTabId;
|
|
2265
2391
|
if (tabId !== null) {
|
|
2266
2392
|
const tab = await chrome.tabs.get(tabId);
|
|
2267
|
-
|
|
2393
|
+
rememberOwnedTabDrift(tab, existing.windowId);
|
|
2394
|
+
if (isDebuggableUrl(tab.url) && tab.windowId === existing.windowId) {
|
|
2395
|
+
await constrainOwnedWindowGeometry(existing.windowId);
|
|
2396
|
+
return existing.windowId;
|
|
2397
|
+
}
|
|
2268
2398
|
}
|
|
2269
2399
|
await chrome.windows.get(existing.windowId);
|
|
2400
|
+
await constrainOwnedWindowGeometry(existing.windowId);
|
|
2270
2401
|
return existing.windowId;
|
|
2271
2402
|
} catch {
|
|
2272
2403
|
await removeLeaseSession(leaseKey);
|
|
@@ -2277,10 +2408,15 @@ async function getAutomationWindow(leaseKey, initialUrl) {
|
|
|
2277
2408
|
return (await ensureOwnedContainerWindow(role, initialUrl, getWindowModeForOwnedPage(leaseKey, url))).windowId;
|
|
2278
2409
|
}
|
|
2279
2410
|
chrome.windows.onRemoved.addListener(async (windowId) => {
|
|
2411
|
+
excludedOwnedWindowIds.delete(windowId);
|
|
2280
2412
|
for (const container of Object.values(ownedContainers)) {
|
|
2281
2413
|
if (container.windowId === windowId) {
|
|
2282
|
-
|
|
2283
|
-
|
|
2414
|
+
const knownGroupId = container.groupId;
|
|
2415
|
+
await rememberOwnedGroupDrift(knownGroupId, windowId);
|
|
2416
|
+
if (container.windowId === windowId && container.groupId === knownGroupId) {
|
|
2417
|
+
container.windowId = null;
|
|
2418
|
+
container.groupId = null;
|
|
2419
|
+
}
|
|
2284
2420
|
}
|
|
2285
2421
|
}
|
|
2286
2422
|
for (const [leaseKey, expected] of [...automationSessions.entries()]) {
|
|
@@ -2535,8 +2671,14 @@ async function resolveTabUnlocked(tabId, leaseKey, initialUrl, blankFirst = fals
|
|
|
2535
2671
|
try {
|
|
2536
2672
|
const tab = await chrome.tabs.get(tabId);
|
|
2537
2673
|
const session = existingSession;
|
|
2674
|
+
if (session?.owned && (session.preferredTabId === tabId || session.preferredTabId === null)) {
|
|
2675
|
+
rememberOwnedTabDrift(tab, session.windowId);
|
|
2676
|
+
}
|
|
2538
2677
|
const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false;
|
|
2539
|
-
if (isDebuggableUrl(tab.url) && matchesSession)
|
|
2678
|
+
if (isDebuggableUrl(tab.url) && matchesSession) {
|
|
2679
|
+
if (session?.owned && tab.windowId === session.windowId) await constrainOwnedWindowGeometry(session.windowId);
|
|
2680
|
+
return { tabId, tab };
|
|
2681
|
+
}
|
|
2540
2682
|
if (session && !session.owned) {
|
|
2541
2683
|
throw new CommandFailure(
|
|
2542
2684
|
matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch",
|
|
@@ -2550,6 +2692,7 @@ async function resolveTabUnlocked(tabId, leaseKey, initialUrl, blankFirst = fals
|
|
|
2550
2692
|
await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 });
|
|
2551
2693
|
const moved = await chrome.tabs.get(tabId);
|
|
2552
2694
|
if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) {
|
|
2695
|
+
if (session.owned) await constrainOwnedWindowGeometry(session.windowId);
|
|
2553
2696
|
return { tabId, tab: moved };
|
|
2554
2697
|
}
|
|
2555
2698
|
} catch (moveErr) {
|
|
@@ -2576,7 +2719,11 @@ async function resolveTabUnlocked(tabId, leaseKey, initialUrl, blankFirst = fals
|
|
|
2576
2719
|
const session = existingSession;
|
|
2577
2720
|
try {
|
|
2578
2721
|
const preferredTab = await chrome.tabs.get(existingPreferredTabId);
|
|
2579
|
-
if (
|
|
2722
|
+
if (session.owned) rememberOwnedTabDrift(preferredTab, session.windowId);
|
|
2723
|
+
if (isDebuggableUrl(preferredTab.url)) {
|
|
2724
|
+
if (session.owned && preferredTab.windowId === session.windowId) await constrainOwnedWindowGeometry(session.windowId);
|
|
2725
|
+
return { tabId: preferredTab.id, tab: preferredTab };
|
|
2726
|
+
}
|
|
2580
2727
|
if (!session.owned) {
|
|
2581
2728
|
throw new CommandFailure(
|
|
2582
2729
|
"bound_tab_not_debuggable",
|
|
@@ -2871,6 +3018,8 @@ async function handleFocusUnlocked(cmd, leaseKey) {
|
|
|
2871
3018
|
if (activatedTab.id !== tabId || activatedTab.windowId !== focusLease.windowId) {
|
|
2872
3019
|
return { id: cmd.id, ok: false, error: "Current automation session tab changed window during focus" };
|
|
2873
3020
|
}
|
|
3021
|
+
await constrainOwnedWindowGeometry(focusLease.windowId);
|
|
3022
|
+
if (!isFocusLeaseCurrent(leaseKey, focusLease)) return focusLeaseChangedResult(cmd.id);
|
|
2874
3023
|
await updateWindow(focusLease.windowId, { focused: true });
|
|
2875
3024
|
if (!isFocusLeaseCurrent(leaseKey, focusLease)) return focusLeaseChangedResult(cmd.id);
|
|
2876
3025
|
return { id: cmd.id, ok: true, data: { focused: true } };
|
|
@@ -2964,12 +3113,16 @@ async function handleTabSelectUnlocked(cmd, leaseKey) {
|
|
|
2964
3113
|
if (!session || tab.windowId !== session.windowId) {
|
|
2965
3114
|
return { id: cmd.id, ok: false, error: "Page is not in the automation container" };
|
|
2966
3115
|
}
|
|
3116
|
+
if (session.owned) await constrainOwnedWindowGeometry(session.windowId);
|
|
2967
3117
|
await chrome.tabs.update(cmdTabId, { active: true });
|
|
2968
3118
|
return pageScopedResult(cmd.id, cmdTabId, { selected: true });
|
|
2969
3119
|
}
|
|
2970
3120
|
const tabs = await listAutomationWebTabsUnlocked(leaseKey);
|
|
2971
3121
|
const target = tabs[cmd.index];
|
|
2972
3122
|
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
3123
|
+
if (lockedSession?.owned && target.windowId === lockedSession.windowId) {
|
|
3124
|
+
await constrainOwnedWindowGeometry(lockedSession.windowId);
|
|
3125
|
+
}
|
|
2973
3126
|
await chrome.tabs.update(target.id, { active: true });
|
|
2974
3127
|
return pageScopedResult(cmd.id, target.id, { selected: true });
|
|
2975
3128
|
}
|
|
@@ -3285,13 +3438,20 @@ async function reconcileTargetLeaseRegistry() {
|
|
|
3285
3438
|
for (const role of Object.keys(ownedContainers)) {
|
|
3286
3439
|
ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null;
|
|
3287
3440
|
ownedContainers[role].groupId = registry.ownedContainers[role]?.groupId ?? null;
|
|
3441
|
+
}
|
|
3442
|
+
for (const role of Object.keys(ownedContainers)) {
|
|
3288
3443
|
const windowId = ownedContainers[role].windowId;
|
|
3444
|
+
const groupId = ownedContainers[role].groupId;
|
|
3289
3445
|
if (windowId !== null) {
|
|
3290
3446
|
try {
|
|
3291
3447
|
await chrome.windows.get(windowId);
|
|
3448
|
+
await constrainOwnedWindowGeometry(windowId);
|
|
3292
3449
|
} catch {
|
|
3293
|
-
|
|
3294
|
-
ownedContainers[role].groupId
|
|
3450
|
+
await rememberOwnedGroupDrift(groupId, windowId);
|
|
3451
|
+
if (ownedContainers[role].windowId === windowId && ownedContainers[role].groupId === groupId) {
|
|
3452
|
+
ownedContainers[role].windowId = null;
|
|
3453
|
+
ownedContainers[role].groupId = null;
|
|
3454
|
+
}
|
|
3295
3455
|
}
|
|
3296
3456
|
}
|
|
3297
3457
|
}
|
|
@@ -3302,6 +3462,8 @@ async function reconcileTargetLeaseRegistry() {
|
|
|
3302
3462
|
try {
|
|
3303
3463
|
const tab = await chrome.tabs.get(tabId);
|
|
3304
3464
|
if (!isDebuggableUrl(tab.url)) continue;
|
|
3465
|
+
if (stored.owned && (!Number.isInteger(stored.windowId) || stored.windowId < 0)) continue;
|
|
3466
|
+
if (stored.owned) rememberOwnedTabDrift(tab, stored.windowId);
|
|
3305
3467
|
if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") {
|
|
3306
3468
|
sessionLifecycleOverrides.set(leaseKey, stored.lifecycle);
|
|
3307
3469
|
}
|
|
@@ -3309,7 +3471,9 @@ async function reconcileTargetLeaseRegistry() {
|
|
|
3309
3471
|
session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey),
|
|
3310
3472
|
surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey),
|
|
3311
3473
|
kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned",
|
|
3312
|
-
|
|
3474
|
+
// A leased tab can be dragged into a user window while the worker is
|
|
3475
|
+
// stopped. Its current location does not transfer window ownership.
|
|
3476
|
+
windowId: stored.owned ? stored.windowId : tab.windowId,
|
|
3313
3477
|
owned: stored.owned,
|
|
3314
3478
|
preferredTabId: tabId
|
|
3315
3479
|
});
|
|
@@ -3319,9 +3483,10 @@ async function reconcileTargetLeaseRegistry() {
|
|
|
3319
3483
|
idleTimer: null,
|
|
3320
3484
|
idleDeadlineAt: stored.idleDeadlineAt
|
|
3321
3485
|
});
|
|
3322
|
-
if (session.owned) {
|
|
3486
|
+
if (session.owned && tab.windowId === session.windowId) {
|
|
3323
3487
|
const role = getOwnedWindowRole(leaseKey);
|
|
3324
3488
|
if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId;
|
|
3489
|
+
await constrainOwnedWindowGeometry(tab.windowId);
|
|
3325
3490
|
await ensureOwnedContainerTabGroup(role, tab.windowId, [tabId]);
|
|
3326
3491
|
}
|
|
3327
3492
|
const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout;
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "byCLI",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.24",
|
|
5
5
|
"description": "Browser automation bridge for the byCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"alarms",
|
|
12
12
|
"storage",
|
|
13
13
|
"tabGroups",
|
|
14
|
+
"system.display",
|
|
14
15
|
"downloads"
|
|
15
16
|
],
|
|
16
17
|
"host_permissions": [
|