browser-workspace 0.4.1 → 0.4.2
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 +1 -1
- package/SKILL.md +42 -20
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +174 -13
- package/package.json +1 -1
- package/platform_runner.py +23 -6
- package/platforms/wechat-channels/actions/_common.py +1 -1
- package/platforms/wechat-channels/actions/_post.py +37 -38
- package/platforms/wechat-channels/manifest.yaml +19 -13
- package/platforms/xhs/actions/_post.py +109 -20
- package/platforms/xhs/manifest.yaml +14 -5
- package/platforms/xhs/tests/test_contract.py +17 -0
- package/platforms/youtube/actions/_post.py +7 -4
- package/session_client.py +21 -11
- package/session_daemon.py +24 -6
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ browser-workspace session stop SESSION_ID
|
|
|
20
20
|
|
|
21
21
|
`--workspace` is hidden from normal CLI help. Normal sessions use `Harness`; product integrations may set an explicit workspace internally when they require isolation.
|
|
22
22
|
|
|
23
|
-
`session stop` closes all tabs created/owned by that session while leaving unrelated browser tabs untouched.
|
|
23
|
+
`session stop` closes all tabs created/owned by that session while leaving unrelated browser tabs untouched. Raw session CDP cannot create targets: `Target.createTarget` is rejected and callers must use `new_tab()` so workspace/session ownership is preserved.
|
|
24
24
|
|
|
25
25
|
## Included layers
|
|
26
26
|
|
package/SKILL.md
CHANGED
|
@@ -18,22 +18,24 @@ $BW_CLI status
|
|
|
18
18
|
|
|
19
19
|
On first use, Browser Workspace bootstraps its own `.venv` automatically.
|
|
20
20
|
|
|
21
|
-
Use one
|
|
21
|
+
Use one session-owned execution path for orchestrated browser work:
|
|
22
22
|
|
|
23
23
|
```text
|
|
24
|
-
|
|
25
|
-
->
|
|
26
|
-
->
|
|
27
|
-
->
|
|
28
|
-
->
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
->
|
|
32
|
-
|
|
33
|
-
->
|
|
24
|
+
start one Browser Workspace session
|
|
25
|
+
-> retain SESSION_ID for the whole workflow
|
|
26
|
+
-> platform run <platform> <action> --session-id SESSION_ID
|
|
27
|
+
-> inspect each result
|
|
28
|
+
-> on recoverable failure, diagnose and self-heal in the SAME session
|
|
29
|
+
-> write every successful site-specific repair back to the platform code
|
|
30
|
+
-> continue until the external workflow reaches a terminal outcome
|
|
31
|
+
-> verify the terminal external state
|
|
32
|
+
finally
|
|
33
|
+
-> stop SESSION_ID
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
`platform run` therefore requires `--session-id` by default. If omitted, the CLI returns a structured error that recommends the `session start -> platform run --session-id -> self-heal -> session stop` workflow. `--auto-session` is an explicit one-shot escape hatch: it creates and cleans up a temporary session automatically, but **does not provide orchestrator self-heal** after failure.
|
|
37
|
+
|
|
38
|
+
There are no separate top-level `open`, `tabs`, `status`, or `screenshot` commands.
|
|
37
39
|
|
|
38
40
|
Every logical browser workflow must follow:
|
|
39
41
|
|
|
@@ -110,7 +112,7 @@ A Python file may also be used with `--code-file`.
|
|
|
110
112
|
$BW_CLI session stop <session_id>
|
|
111
113
|
```
|
|
112
114
|
|
|
113
|
-
Stopping closes every tab owned or opened by that session—including tabs created with `new_tab()`, `bh.new_tab()`,
|
|
115
|
+
Stopping closes every tab owned or opened by that session—including tabs created with `new_tab()`, `bh.new_tab()`, and detected child/popup tabs—then destroys the session's Python namespace. Raw `cdp("Target.createTarget", ...)` is rejected; use `new_tab()` so Browser Workspace can own and group the tab. It does not close unrelated browser tabs. Do not reuse the ID afterward.
|
|
114
116
|
|
|
115
117
|
A safe shell structure is:
|
|
116
118
|
|
|
@@ -216,21 +218,41 @@ platforms/<name>/
|
|
|
216
218
|
|
|
217
219
|
Platform rules:
|
|
218
220
|
|
|
219
|
-
- Platform actions may use browser helpers such as `new_tab()`, `current_tab()`, `snapshot()`, `js()`, `cdp()`, uploads, downloads, and screenshots.
|
|
221
|
+
- Platform actions may use semantic browser helpers such as `new_tab()`, `current_tab()`, `snapshot()`, `click(selector)`, `fill_input(selector, text)`, `js()`, `cdp()`, uploads, downloads, and screenshots. `cdp()` must not create targets; `Target.createTarget` is blocked and tab creation must use `new_tab()`.
|
|
222
|
+
- Platform interaction code must be **semantic and element-based**. It must not use screen coordinates, viewport coordinates, `x/y` click targets, `click_at_xy()`, or coordinate calculations to interact with the page. Browser size, zoom, display resolution, and viewport geometry must not affect correctness.
|
|
220
223
|
- Platform actions must **not** create/stop Browser Workspace sessions, choose a workspace, call workspace-management APIs, invoke the Browser Workspace CLI, or import session/workspace infrastructure.
|
|
224
|
+
- The orchestrator owns the session lifecycle for normal platform execution: start once, retain the same session across execution and recovery, and stop only after terminal success, terminal failure, or an explicit human blocker.
|
|
225
|
+
- On any recoverable failure, the orchestrator must inspect the live page in the same session, diagnose the failure, repair/recover, and resume from the safest point rather than restarting the whole workflow.
|
|
226
|
+
- **Self-heal writeback is mandatory:** if recovery discovers a site-specific selector, flow, wait condition, validation rule, or other adapter fix that makes the action work, write that repair back to `platforms/<name>/actions/` (and update platform docs/verification metadata when applicable) before declaring the workflow complete. A repair that exists only in an ad-hoc session snippet is not complete.
|
|
227
|
+
- After a possible external side effect (publish, send, payment, delete, submit, etc.), recovery must be verification-first. Never repeat the side effect until read-only evidence proves the previous attempt did not take effect.
|
|
221
228
|
- Platform `README.md` and flow docs should describe site behavior, preconditions, side effects, and verification evidence—not Browser Workspace lifecycle mechanics.
|
|
222
|
-
- Platform actions must remain usable in either a generic runner-owned session or a product-owned browser context.
|
|
223
|
-
- The generic runner owns the normal lifecycle: start a default Harness session, inject browser helpers/config, execute the action, then always stop the session and close its owned tabs.
|
|
224
|
-
- Products that require a dedicated or persistent browser context (for example Family Tutor or Agents Relay) may execute the same platform action inside product-owned infrastructure. The platform code itself stays unchanged.
|
|
225
229
|
- Do not rediscover a known site's mechanics when a verified platform flow/action already exists.
|
|
226
230
|
|
|
227
|
-
|
|
231
|
+
Normal orchestrator workflow:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
SESSION_JSON=$($BW_CLI session start --url https://example.com)
|
|
235
|
+
SESSION_ID=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["session_id"])' "$SESSION_JSON")
|
|
236
|
+
|
|
237
|
+
$BW_CLI platform run microsoft-teams meeting \
|
|
238
|
+
--session-id "$SESSION_ID" \
|
|
239
|
+
--config /path/to/config.json
|
|
240
|
+
|
|
241
|
+
# If it fails: inspect/self-heal using the same SESSION_ID, write the repair
|
|
242
|
+
# back to the platform implementation, then resume safely.
|
|
243
|
+
|
|
244
|
+
$BW_CLI session stop "$SESSION_ID"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
One-shot mode is explicit and is not self-healing:
|
|
228
248
|
|
|
229
249
|
```bash
|
|
230
|
-
$BW_CLI platform run microsoft-teams meeting
|
|
250
|
+
$BW_CLI platform run microsoft-teams meeting \
|
|
251
|
+
--auto-session \
|
|
252
|
+
--config /path/to/config.json
|
|
231
253
|
```
|
|
232
254
|
|
|
233
|
-
|
|
255
|
+
Use `--auto-session` only when losing the browser state after a failure is acceptable.
|
|
234
256
|
|
|
235
257
|
### Platform verification contract
|
|
236
258
|
|
|
Binary file
|
package/bin/browser-workspace
CHANGED
|
@@ -30,18 +30,165 @@ def status():
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
def main():
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
fmt=argparse.RawDescriptionHelpFormatter
|
|
34
|
+
p=argparse.ArgumentParser(
|
|
35
|
+
prog='browser-workspace',
|
|
36
|
+
description='Control the shared Browser Workspace Chrome, browser sessions, and platform automations.',
|
|
37
|
+
epilog='''Examples:
|
|
38
|
+
browser-workspace status
|
|
39
|
+
browser-workspace session start --url https://example.com
|
|
40
|
+
browser-workspace session list
|
|
41
|
+
browser-workspace session exec SESSION_ID --code-file script.py
|
|
42
|
+
browser-workspace session stop SESSION_ID
|
|
43
|
+
browser-workspace session stop --all
|
|
44
|
+
browser-workspace platform profile wechat-channels
|
|
45
|
+
browser-workspace platform run wechat-channels manage --session-id SESSION_ID --config status.json
|
|
46
|
+
browser-workspace platform run wechat-channels post --session-id SESSION_ID --config post.json
|
|
47
|
+
browser-workspace platform run wechat-channels manage --auto-session --config status.json
|
|
48
|
+
''',
|
|
49
|
+
formatter_class=fmt,
|
|
50
|
+
)
|
|
51
|
+
sp=p.add_subparsers(dest='group',required=True,title='commands',metavar='COMMAND')
|
|
52
|
+
|
|
53
|
+
sp.add_parser(
|
|
54
|
+
'status',
|
|
55
|
+
help='Show Browser Workspace runtime, managed Chrome, daemon, and active-session status.',
|
|
56
|
+
description='Show Browser Workspace runtime, managed Chrome, daemon, and active-session status.',
|
|
57
|
+
epilog='Example:\n browser-workspace status',
|
|
58
|
+
formatter_class=fmt,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
session=sp.add_parser(
|
|
62
|
+
'session',
|
|
63
|
+
help='Create, inspect, execute code in, and stop browser sessions.',
|
|
64
|
+
description='Manage Browser Workspace sessions.',
|
|
65
|
+
epilog='''Examples:
|
|
66
|
+
browser-workspace session start --url https://example.com
|
|
67
|
+
browser-workspace session list
|
|
68
|
+
browser-workspace session exec SESSION_ID --code-file script.py
|
|
69
|
+
cat script.py | browser-workspace session exec SESSION_ID
|
|
70
|
+
browser-workspace session stop SESSION_ID
|
|
71
|
+
browser-workspace session stop --all
|
|
72
|
+
''',
|
|
73
|
+
formatter_class=fmt,
|
|
74
|
+
)
|
|
75
|
+
ssp=session.add_subparsers(dest='cmd',required=True,title='session commands',metavar='COMMAND')
|
|
76
|
+
|
|
77
|
+
start=ssp.add_parser(
|
|
78
|
+
'start',
|
|
79
|
+
help='Start a browser session, optionally opening a URL.',
|
|
80
|
+
description='Start a browser session. The shared managed Chrome is reused; this does not launch a separate browser per session.',
|
|
81
|
+
epilog='''Examples:
|
|
82
|
+
browser-workspace session start
|
|
83
|
+
browser-workspace session start --url https://example.com
|
|
84
|
+
''',
|
|
85
|
+
formatter_class=fmt,
|
|
86
|
+
)
|
|
87
|
+
start.add_argument('--workspace', help=argparse.SUPPRESS)
|
|
88
|
+
start.add_argument('--url', metavar='URL', help='URL to open when the session starts.')
|
|
89
|
+
|
|
90
|
+
ssp.add_parser(
|
|
91
|
+
'list',
|
|
92
|
+
help='List currently active Browser Workspace sessions.',
|
|
93
|
+
description='List currently active Browser Workspace sessions and their target tabs.',
|
|
94
|
+
epilog='Example:\n browser-workspace session list',
|
|
95
|
+
formatter_class=fmt,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
ex=ssp.add_parser(
|
|
99
|
+
'exec',
|
|
100
|
+
help='Execute Browser Workspace Python helpers inside an existing session.',
|
|
101
|
+
description='Execute Python using Browser Workspace helpers in an existing session. Read code from --code-file or stdin.',
|
|
102
|
+
epilog='''Examples:
|
|
103
|
+
browser-workspace session exec SESSION_ID --code-file script.py
|
|
104
|
+
|
|
105
|
+
browser-workspace session exec SESSION_ID <<'PY'
|
|
106
|
+
print(page_info())
|
|
107
|
+
print(snapshot(interactive_only=False))
|
|
108
|
+
PY
|
|
109
|
+
''',
|
|
110
|
+
formatter_class=fmt,
|
|
111
|
+
)
|
|
112
|
+
ex.add_argument('session_id', metavar='SESSION_ID', help='Session ID returned by "session start" or "session list".')
|
|
113
|
+
ex.add_argument('--code-file', metavar='PATH', help='Read Python code from this file instead of stdin.')
|
|
114
|
+
|
|
115
|
+
stop=ssp.add_parser(
|
|
116
|
+
'stop',
|
|
117
|
+
help='Stop one session or all sessions and release their Browser Workspace tabs.',
|
|
118
|
+
description='Stop one Browser Workspace session, or all sessions with --all.',
|
|
119
|
+
epilog='''Examples:
|
|
120
|
+
browser-workspace session stop SESSION_ID
|
|
121
|
+
browser-workspace session stop --all
|
|
122
|
+
''',
|
|
123
|
+
formatter_class=fmt,
|
|
124
|
+
)
|
|
125
|
+
stop.add_argument('session_id', nargs='?', metavar='SESSION_ID', help='Session ID to stop.')
|
|
126
|
+
stop.add_argument('--all', action='store_true', dest='stop_all', help='Stop all active Browser Workspace sessions.')
|
|
127
|
+
|
|
128
|
+
platform=sp.add_parser(
|
|
129
|
+
'platform',
|
|
130
|
+
help='Inspect and run reusable website-specific platform automations.',
|
|
131
|
+
description='Inspect and run reusable Browser Workspace platform automations.',
|
|
132
|
+
epilog='''Examples:
|
|
133
|
+
browser-workspace platform profile wechat-channels
|
|
134
|
+
browser-workspace platform source wechat-channels post --config post.json
|
|
135
|
+
browser-workspace session start --url https://channels.weixin.qq.com/platform/post/list
|
|
136
|
+
browser-workspace platform run wechat-channels manage --session-id SESSION_ID --config status.json
|
|
137
|
+
browser-workspace platform run wechat-channels post --session-id SESSION_ID --config post.json
|
|
138
|
+
browser-workspace session stop SESSION_ID
|
|
139
|
+
|
|
140
|
+
One-shot mode (no orchestrator self-heal):
|
|
141
|
+
browser-workspace platform run wechat-channels manage --auto-session --config status.json
|
|
142
|
+
''',
|
|
143
|
+
formatter_class=fmt,
|
|
144
|
+
)
|
|
145
|
+
psp=platform.add_subparsers(dest='pcmd',required=True,title='platform commands',metavar='COMMAND')
|
|
146
|
+
|
|
147
|
+
profile=psp.add_parser(
|
|
148
|
+
'profile',
|
|
149
|
+
help='Show a platform manifest/profile: fields, media rules, and capabilities.',
|
|
150
|
+
description='Show the declared Browser Workspace profile for a platform.',
|
|
151
|
+
epilog='Example:\n browser-workspace platform profile wechat-channels',
|
|
152
|
+
formatter_class=fmt,
|
|
153
|
+
)
|
|
154
|
+
profile.add_argument('platform', metavar='PLATFORM', help='Platform name, for example "wechat-channels".')
|
|
155
|
+
|
|
156
|
+
source=psp.add_parser(
|
|
157
|
+
'source',
|
|
158
|
+
help='Print the fully prepared Python source for one platform action without running it.',
|
|
159
|
+
description='Render the action source after injecting the optional JSON config path. No browser action is executed.',
|
|
160
|
+
epilog='''Examples:
|
|
161
|
+
browser-workspace platform source wechat-channels post --config post.json
|
|
162
|
+
browser-workspace platform source wechat-channels manage --config status.json
|
|
163
|
+
''',
|
|
164
|
+
formatter_class=fmt,
|
|
165
|
+
)
|
|
166
|
+
source.add_argument('platform', metavar='PLATFORM', help='Platform name, for example "wechat-channels".')
|
|
167
|
+
source.add_argument('action', metavar='ACTION', help='Declared action name, for example "post" or "manage".')
|
|
168
|
+
source.add_argument('--config', metavar='PATH', help='JSON configuration file used to prepare the action.')
|
|
169
|
+
|
|
170
|
+
run=psp.add_parser(
|
|
171
|
+
'run',
|
|
172
|
+
help='Run one platform action in Browser Workspace.',
|
|
173
|
+
description='Run a declared platform action. A caller-owned --session-id is required by default so an orchestrator can inspect failures, self-heal in the same browser state, write repairs back to platform code, and close the session only after completion. --auto-session is an explicit one-shot mode without orchestrator self-heal.',
|
|
174
|
+
epilog='''Examples:
|
|
175
|
+
browser-workspace session start --url https://channels.weixin.qq.com/platform/post/list
|
|
176
|
+
browser-workspace platform run wechat-channels manage --session-id SESSION_ID --config status.json
|
|
177
|
+
browser-workspace platform run wechat-channels post --session-id SESSION_ID --config post.json
|
|
178
|
+
browser-workspace session stop SESSION_ID
|
|
179
|
+
|
|
180
|
+
One-shot mode (no orchestrator self-heal):
|
|
181
|
+
browser-workspace platform run wechat-channels manage --auto-session --config status.json
|
|
182
|
+
''',
|
|
183
|
+
formatter_class=fmt,
|
|
184
|
+
)
|
|
185
|
+
run.add_argument('platform', metavar='PLATFORM', help='Platform name, for example "wechat-channels".')
|
|
186
|
+
run.add_argument('action', metavar='ACTION', help='Declared action name, for example "post" or "manage".')
|
|
187
|
+
run.add_argument('--config', metavar='PATH', help='JSON configuration file for the platform action.')
|
|
188
|
+
run.add_argument('--url', metavar='URL', help='Optional initial URL for a temporary session.')
|
|
189
|
+
session_mode = run.add_mutually_exclusive_group()
|
|
190
|
+
session_mode.add_argument('--session-id', metavar='SESSION_ID', help='Required by default. Reuse a caller-owned session so failures can be inspected and self-healed before the session is closed.')
|
|
191
|
+
session_mode.add_argument('--auto-session', action='store_true', help='Explicit one-shot mode: create and clean up a temporary session automatically. No orchestrator self-heal is available after failure.')
|
|
45
192
|
args=p.parse_args()
|
|
46
193
|
if args.group=='status': emit(status()); return
|
|
47
194
|
if args.group=='session' and args.cmd=='list':
|
|
@@ -71,5 +218,19 @@ def main():
|
|
|
71
218
|
from platform_runner import platform_profile
|
|
72
219
|
emit(platform_profile(args.platform)); return
|
|
73
220
|
if args.group=='platform' and args.pcmd=='run':
|
|
74
|
-
|
|
221
|
+
if not args.session_id and not args.auto_session:
|
|
222
|
+
emit({
|
|
223
|
+
'ok': False,
|
|
224
|
+
'error': 'session_id_required',
|
|
225
|
+
'message': 'platform run requires --session-id by default so the orchestrator can self-heal failures in the same browser session.',
|
|
226
|
+
'suggested_workflow': [
|
|
227
|
+
'browser-workspace session start --url <URL>',
|
|
228
|
+
'browser-workspace platform run <PLATFORM> <ACTION> --session-id SESSION_ID [--config FILE]',
|
|
229
|
+
'inspect/self-heal failures in the same SESSION_ID and write successful repairs back to platform code',
|
|
230
|
+
'browser-workspace session stop SESSION_ID',
|
|
231
|
+
],
|
|
232
|
+
'auto_session': 'Use --auto-session only for a one-shot run. It creates and closes its own session and does not provide orchestrator self-heal.',
|
|
233
|
+
})
|
|
234
|
+
raise SystemExit(2)
|
|
235
|
+
r=run_platform_action(args.platform,args.action,config_path=args.config,url=args.url,session_id=args.session_id,auto_session=args.auto_session); emit(r); raise SystemExit(0 if (r.get('result') or {}).get('ok',False) else 1)
|
|
75
236
|
if __name__=='__main__': main()
|
package/package.json
CHANGED
package/platform_runner.py
CHANGED
|
@@ -102,11 +102,16 @@ def declared_actions(platform: str) -> dict[str, Path]:
|
|
|
102
102
|
raise ValueError(f"{platform}/manifest.yaml has no actions block")
|
|
103
103
|
|
|
104
104
|
actions: dict[str, Path] = {}
|
|
105
|
-
for
|
|
106
|
-
r'^\s{2}([\w-]+):\s*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
for line in match.group("body").splitlines():
|
|
106
|
+
entry = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
|
|
107
|
+
if not entry:
|
|
108
|
+
continue
|
|
109
|
+
name, relative = entry.groups()
|
|
110
|
+
if len(relative) >= 2 and relative[0] == relative[-1] and relative[0] in {"\"", "'"}:
|
|
111
|
+
relative = relative[1:-1]
|
|
112
|
+
# Actions are simple relative file paths; comments and nested mappings are not runnable actions.
|
|
113
|
+
if not relative or relative.startswith(("#", "{", "[")) or " #" in relative:
|
|
114
|
+
continue
|
|
110
115
|
path = PLATFORMS / platform / relative
|
|
111
116
|
if not path.is_file():
|
|
112
117
|
raise FileNotFoundError(f"declared platform action missing: {platform}/{name} -> {relative}")
|
|
@@ -146,6 +151,7 @@ def run_platform_action(
|
|
|
146
151
|
config_path: str | None = None,
|
|
147
152
|
url: str | None = None,
|
|
148
153
|
session_id: str | None = None,
|
|
154
|
+
auto_session: bool = False,
|
|
149
155
|
) -> dict:
|
|
150
156
|
path = action_path(platform, action)
|
|
151
157
|
config_file = Path(config_path) if config_path else None
|
|
@@ -156,7 +162,16 @@ def run_platform_action(
|
|
|
156
162
|
validate_platform_config(platform, action, config)
|
|
157
163
|
code = prepare_action(path, config_file)
|
|
158
164
|
|
|
159
|
-
|
|
165
|
+
if not session_id and not auto_session:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"platform run requires session_id; orchestrators should use: "
|
|
168
|
+
"session start -> platform run --session-id SESSION_ID -> self-heal in the same session -> session stop. "
|
|
169
|
+
"Use auto_session=True only for a one-shot run without orchestrator self-heal."
|
|
170
|
+
)
|
|
171
|
+
if session_id and auto_session:
|
|
172
|
+
raise ValueError("session_id and auto_session are mutually exclusive")
|
|
173
|
+
|
|
174
|
+
owned_session = bool(auto_session)
|
|
160
175
|
started = request({"op": "start", "workspace": None, "url": url}) if owned_session else {"session_id": session_id, "workspace_supported": None}
|
|
161
176
|
sid = started["session_id"]
|
|
162
177
|
result = None
|
|
@@ -168,6 +183,8 @@ def run_platform_action(
|
|
|
168
183
|
"platform": platform,
|
|
169
184
|
"action": action,
|
|
170
185
|
"workspace_supported": started.get("workspace_supported"),
|
|
186
|
+
"session_mode": "auto_session_no_self_heal" if owned_session else "caller_owned_self_heal_capable",
|
|
187
|
+
"self_heal_capable": not owned_session,
|
|
171
188
|
"result": result,
|
|
172
189
|
}
|
|
173
190
|
if started.get("warning"):
|
|
@@ -6,7 +6,7 @@ import re
|
|
|
6
6
|
from typing import Any, Optional
|
|
7
7
|
from urllib.parse import parse_qs, urlparse
|
|
8
8
|
|
|
9
|
-
MANAGER_URL = "https://channels.weixin.qq.com/platform/post"
|
|
9
|
+
MANAGER_URL = "https://channels.weixin.qq.com/platform/post/list"
|
|
10
10
|
|
|
11
11
|
STATUS_LABELS: tuple[tuple[str, str], ...] = (
|
|
12
12
|
("审核中", "reviewing"),
|
|
@@ -30,13 +30,6 @@ def open_or_reuse_wechat(url):
|
|
|
30
30
|
def shadow_js(expr):
|
|
31
31
|
return js(SB + expr)
|
|
32
32
|
|
|
33
|
-
def find_rect(sel):
|
|
34
|
-
code = SB + '.querySelector("' + sel + '") ? (function(){ var r = ' + SB + '.querySelector("' + sel + '").getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+r.width/2), y:Math.round(r.y+r.height/2)}); })() : "null"'
|
|
35
|
-
r = js(code)
|
|
36
|
-
if r and r != "null":
|
|
37
|
-
return json.loads(r)
|
|
38
|
-
return None
|
|
39
|
-
|
|
40
33
|
print("[1/6] Opening or reusing the WeChat Channels create tab...")
|
|
41
34
|
open_or_reuse_wechat("https://channels.weixin.qq.com/platform/post/create")
|
|
42
35
|
wait_for_load()
|
|
@@ -87,17 +80,23 @@ else:
|
|
|
87
80
|
print(" -> Waiting for upload...")
|
|
88
81
|
for attempt in range(120):
|
|
89
82
|
time.sleep(5)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
upload_state = js(SB + '.innerText ? (function(text){ var match=text.match(/(?:^|\\n)(\\d{1,3})%(?:\\n|$)/); var percent=match?parseInt(match[1],10):null; var uploading=text.indexOf(\"取消上传\")>=0; return JSON.stringify({percent:percent,uploading:uploading}); })(' + SB + '.innerText) : JSON.stringify({percent:null,uploading:false})')
|
|
84
|
+
try:
|
|
85
|
+
state = json.loads(upload_state or "{}")
|
|
86
|
+
except Exception:
|
|
87
|
+
state = {}
|
|
88
|
+
# Do not infer completion from editor/preview presence or Publish enabled state:
|
|
89
|
+
# those can appear while the video is still uploading.
|
|
90
|
+
if state.get("percent") is None and not state.get("uploading"):
|
|
93
91
|
print(" -> Upload done!")
|
|
94
92
|
break
|
|
95
|
-
if attempt %
|
|
96
|
-
pct =
|
|
97
|
-
print(" -> Uploading... " + str(pct) + " (" + str((attempt+1)*5) + "s)")
|
|
93
|
+
if attempt % 3 == 2:
|
|
94
|
+
pct = state.get("percent")
|
|
95
|
+
print(" -> Uploading... " + ((str(pct) + "%") if pct is not None else "processing") + " (" + str((attempt+1)*5) + "s)")
|
|
98
96
|
else:
|
|
99
|
-
print("
|
|
97
|
+
print("ERROR: Upload did not reach a completed state before timeout.")
|
|
100
98
|
capture_screenshot()
|
|
99
|
+
raise SystemExit(1)
|
|
101
100
|
time.sleep(2)
|
|
102
101
|
|
|
103
102
|
print("[3/6] Setting description...")
|
|
@@ -115,13 +114,12 @@ actual = shadow_js('.querySelector(".input-editor")?.textContent?.substring(0,50
|
|
|
115
114
|
if actual:
|
|
116
115
|
print(" -> Description: " + actual)
|
|
117
116
|
else:
|
|
118
|
-
print(" -> WARNING: Description may not have been set
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
time.sleep(0.5)
|
|
117
|
+
print(" -> WARNING: Description may not have been set; retrying through the semantic editor element...")
|
|
118
|
+
retry_desc = json.dumps(CFG["desc"])
|
|
119
|
+
js(SB + '.querySelector(".input-editor")?.focus()')
|
|
120
|
+
js(SB + '.querySelector(".input-editor").textContent = ' + retry_desc)
|
|
121
|
+
js(SB + '.querySelector(".input-editor").dispatchEvent(new Event("input", {bubbles:true}))')
|
|
122
|
+
time.sleep(0.5)
|
|
125
123
|
|
|
126
124
|
if CFG["title"]:
|
|
127
125
|
print("[4/6] Setting short title: " + CFG["title"])
|
|
@@ -135,28 +133,30 @@ else:
|
|
|
135
133
|
print("[4/6] No short title, skipping...")
|
|
136
134
|
|
|
137
135
|
# Scroll down to make buttons visible
|
|
138
|
-
shadow_js('.scrollTo(0, 99999)')
|
|
139
|
-
js('document.querySelector("wujie-app").shadowRoot.querySelector("html")?.scrollTo(0, 99999)')
|
|
140
|
-
time.sleep(1)
|
|
141
|
-
|
|
142
136
|
print("[5/6] Pre-action screenshot...")
|
|
143
137
|
capture_screenshot()
|
|
144
138
|
time.sleep(1)
|
|
145
139
|
|
|
146
|
-
def
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
def click_btn_by_text(text):
|
|
141
|
+
# Semantic DOM click only. Never derive or use viewport/screen coordinates.
|
|
142
|
+
text_s = json.dumps(text)
|
|
143
|
+
return js(
|
|
144
|
+
'(function(){ var body=' + SB + '; var target=null; '
|
|
145
|
+
'body.querySelectorAll(".weui-desktop-btn").forEach(function(btn){ '
|
|
146
|
+
'var style=getComputedStyle(btn); '
|
|
147
|
+
'if(btn.textContent.trim()===' + text_s + ' && '
|
|
148
|
+
'!btn.disabled && !btn.classList.contains("weui-desktop-btn_disabled") && '
|
|
149
|
+
'style.display!=="none" && style.visibility!=="hidden"){ target=btn; } }); '
|
|
150
|
+
'if(!target) return false; target.click(); return true; })()'
|
|
151
|
+
)
|
|
152
|
+
|
|
152
153
|
|
|
153
154
|
if CFG["publish"]:
|
|
154
155
|
print("[6/6] Publishing...")
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
click_at_xy(c["x"], c["y"])
|
|
156
|
+
clicked = click_btn_by_text("发表")
|
|
157
|
+
if clicked:
|
|
158
158
|
time.sleep(3)
|
|
159
|
-
# Handle confirmation dialog
|
|
159
|
+
# Handle confirmation dialog semantically as well.
|
|
160
160
|
shadow_js('.querySelectorAll(".weui-desktop-dialog__wrp").forEach(function(d){ if(d.style.display !== "none"){ var b = d.querySelector(".weui-desktop-btn_primary"); if(b) b.click(); }})')
|
|
161
161
|
time.sleep(3)
|
|
162
162
|
print(" -> Publish action submitted; verifying manager state...")
|
|
@@ -167,9 +167,8 @@ if CFG["publish"]:
|
|
|
167
167
|
raise SystemExit(1)
|
|
168
168
|
else:
|
|
169
169
|
print("[6/6] Saving draft...")
|
|
170
|
-
|
|
171
|
-
if
|
|
172
|
-
click_at_xy(c["x"], c["y"])
|
|
170
|
+
clicked = click_btn_by_text("保存草稿")
|
|
171
|
+
if clicked:
|
|
173
172
|
time.sleep(3)
|
|
174
173
|
print(" -> Draft action submitted; verifying manager state...")
|
|
175
174
|
action = "draft"
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
platform: wechat-channels
|
|
2
|
-
status:
|
|
3
|
-
last_verified:
|
|
2
|
+
status: partially_verified
|
|
3
|
+
last_verified: '2026-10-04'
|
|
4
4
|
actions:
|
|
5
5
|
post: actions/_post.py
|
|
6
6
|
manage: actions/_manage.py
|
|
7
7
|
verification:
|
|
8
|
-
status:
|
|
9
|
-
last_verified:
|
|
10
|
-
evidence:
|
|
11
|
-
|
|
8
|
+
status: partially_verified
|
|
9
|
+
last_verified: '2026-10-04'
|
|
10
|
+
evidence: Live publish succeeded with creator confirmation 已发表 and manager list
|
|
11
|
+
count increasing to 4. Exact title/description manager-row matching remains unverified
|
|
12
|
+
because list rows do not expose those fields in visible text.
|
|
12
13
|
url_patterns:
|
|
13
14
|
- https://channels.weixin.qq.com/*
|
|
14
15
|
authentication:
|
|
@@ -19,17 +20,18 @@ provenance:
|
|
|
19
20
|
migrated_code: true
|
|
20
21
|
flows:
|
|
21
22
|
post:
|
|
22
|
-
status:
|
|
23
|
-
evidence:
|
|
23
|
+
status: verified
|
|
24
|
+
evidence: Live publish succeeded on 2026-10-04; creator UI showed 已发表 and manager
|
|
25
|
+
list gained a new 10:33 row.
|
|
24
26
|
manage:
|
|
25
|
-
status:
|
|
26
|
-
evidence:
|
|
27
|
-
|
|
27
|
+
status: partially_verified
|
|
28
|
+
evidence: Live manager list at /platform/post/list loaded and exposed video count/timestamps;
|
|
29
|
+
exact title/description row matching is not yet verified.
|
|
28
30
|
display_name: WeChat Channels
|
|
29
31
|
aliases:
|
|
30
32
|
- wechat
|
|
31
33
|
- channels
|
|
32
|
-
implementation_status:
|
|
34
|
+
implementation_status: live_publish_verified_manager_matching_partial
|
|
33
35
|
media:
|
|
34
36
|
modes:
|
|
35
37
|
- video
|
|
@@ -49,12 +51,14 @@ fields:
|
|
|
49
51
|
implemented: true
|
|
50
52
|
title:
|
|
51
53
|
cli: --title
|
|
52
|
-
required:
|
|
54
|
+
required: true
|
|
53
55
|
type: string
|
|
54
56
|
min_chars: 6
|
|
55
57
|
max_chars: 16
|
|
56
58
|
implemented: true
|
|
57
59
|
meaning: short title
|
|
60
|
+
note: Live creator UI on 2026-10-04 kept Publish disabled until a valid short
|
|
61
|
+
title was provided.
|
|
58
62
|
tags:
|
|
59
63
|
cli: --tags
|
|
60
64
|
required: false
|
|
@@ -116,3 +120,5 @@ notes:
|
|
|
116
120
|
state before any deliberate retry.
|
|
117
121
|
- Update/edit, comment reading, comments, and replies are intentionally unimplemented
|
|
118
122
|
until their exact creator UI is observed.
|
|
123
|
+
- Live creator UI requires a valid short title before Publish becomes enabled; treat
|
|
124
|
+
title as required for post actions.
|
|
@@ -26,6 +26,8 @@ def open_or_reuse_xhs(url):
|
|
|
26
26
|
images = CFG.get("images", [CFG.get("image", "")])
|
|
27
27
|
video = CFG.get("video", "")
|
|
28
28
|
tags = CFG.get("tags", [])
|
|
29
|
+
collection = (CFG.get("collection") or "").strip()
|
|
30
|
+
collection_description = (CFG.get("collection_description") or "").strip()
|
|
29
31
|
|
|
30
32
|
# Calculate steps dynamically
|
|
31
33
|
base_steps = 6 # navigate, tab, upload, title, body, action
|
|
@@ -82,20 +84,21 @@ if mode == "video":
|
|
|
82
84
|
raise SystemExit(1)
|
|
83
85
|
|
|
84
86
|
next_step("Waiting for video upload...")
|
|
85
|
-
for attempt in range(
|
|
86
|
-
time.sleep(
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
|
|
87
|
+
for attempt in range(180):
|
|
88
|
+
time.sleep(1)
|
|
89
|
+
# A preview/video element appears immediately, even while XHS still says
|
|
90
|
+
# "上传中 0%". Treat the editor's completed state as authoritative.
|
|
91
|
+
upload_state = js('(function(){var t=document.body.innerText||""; if(/上传中\\s*\\d*%?/.test(t)||t.indexOf("取消上传")>=0)return "uploading"; if(t.indexOf("重新上传")>=0||t.indexOf("检测为高清视频")>=0)return "ready"; return "waiting"})()')
|
|
92
|
+
if upload_state == "ready":
|
|
93
|
+
print(" -> Upload processed and editor-ready!")
|
|
91
94
|
break
|
|
92
|
-
if attempt %
|
|
95
|
+
if attempt % 15 == 14:
|
|
93
96
|
pct = js('var el=document.querySelector("[class*=progress],.percent,[class*=uploadProgress]"); el ? el.textContent : "..."')
|
|
94
|
-
print(f" ->
|
|
97
|
+
print(f" -> Upload state {upload_state}; {pct} ({attempt+1}s)")
|
|
95
98
|
else:
|
|
96
|
-
print("
|
|
99
|
+
print("ERROR: video upload never reached the editor-ready state")
|
|
97
100
|
capture_screenshot()
|
|
98
|
-
|
|
101
|
+
raise SystemExit(1)
|
|
99
102
|
|
|
100
103
|
else:
|
|
101
104
|
# Image mode — click the 2nd tab (上传图文)
|
|
@@ -153,32 +156,118 @@ time.sleep(0.5)
|
|
|
153
156
|
if tags:
|
|
154
157
|
next_step(f"Tags appended: {', '.join(tags)}")
|
|
155
158
|
|
|
159
|
+
def choose_collection(name, description):
|
|
160
|
+
if not name:
|
|
161
|
+
return None
|
|
162
|
+
opened = js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){var t=(e.innerText||e.textContent||"").trim(); return e.offsetParent && e.children.length===0 && t==="选择合集"}); if(es.length){es[0].click();"yes"}else{"no"}')
|
|
163
|
+
if opened != "yes":
|
|
164
|
+
raise RuntimeError("collection chooser not found")
|
|
165
|
+
time.sleep(.5)
|
|
166
|
+
selected = js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){return e.offsetParent && e.children.length===0 && (e.innerText||e.textContent||"").trim()===' + json.dumps(name) + '}); if(es.length){es[0].click();"yes"}else{"no"}')
|
|
167
|
+
if selected == "yes":
|
|
168
|
+
time.sleep(.5)
|
|
169
|
+
return "selected"
|
|
170
|
+
create = js('var es=Array.from(document.querySelectorAll("button,*")).filter(function(e){return e.offsetParent && e.children.length===0 && (e.innerText||e.textContent||"").trim()==="创建合集"}); if(es.length){es[0].click();"yes"}else{"no"}')
|
|
171
|
+
if create != "yes":
|
|
172
|
+
raise RuntimeError("collection not found and create control missing")
|
|
173
|
+
time.sleep(.5)
|
|
174
|
+
fill_input('input[placeholder="好的合集名称能吸引更多用户"]', name, clear_first=True, timeout=20)
|
|
175
|
+
if description:
|
|
176
|
+
fill_input('input[placeholder="简单介绍你的合集"],textarea[placeholder="简单介绍你的合集"]', description, clear_first=True, timeout=20)
|
|
177
|
+
created = js('var b=Array.from(document.querySelectorAll("button")).find(function(e){return e.offsetParent && (e.innerText||e.textContent||"").trim()==="创建并加入"}); if(b&&!b.disabled){b.click();"yes"}else{"no"}')
|
|
178
|
+
if created != "yes":
|
|
179
|
+
raise RuntimeError("create-and-join collection control unavailable")
|
|
180
|
+
for _ in range(20):
|
|
181
|
+
time.sleep(.5)
|
|
182
|
+
visible = js('(document.body.innerText||"").indexOf(' + json.dumps(name) + ')>=0 && (document.body.innerText||"").indexOf("创建并加入")<0 ? "yes" : "no"')
|
|
183
|
+
if visible == "yes":
|
|
184
|
+
return "created"
|
|
185
|
+
raise RuntimeError("collection creation was not verified")
|
|
186
|
+
|
|
187
|
+
if collection:
|
|
188
|
+
next_step("Joining collection: " + collection)
|
|
189
|
+
print(" -> Collection " + choose_collection(collection, collection_description))
|
|
190
|
+
|
|
191
|
+
def creator_manager_receipt(title):
|
|
192
|
+
goto_url("https://creator.xiaohongshu.com/new/note-manager")
|
|
193
|
+
try:
|
|
194
|
+
wait_for_load()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
rows = []
|
|
198
|
+
for _ in range(30):
|
|
199
|
+
time.sleep(.5)
|
|
200
|
+
raw = js('Array.from(document.querySelectorAll(".note-card")).map(function(c){var t=c.querySelector(".note-card__title"); return JSON.stringify({title:t?(t.innerText||t.textContent||"").trim():"", text:(c.innerText||"").trim(), impression:c.dataset.impression||""})}).join("\\n")') or ""
|
|
201
|
+
rows = []
|
|
202
|
+
for line in raw.splitlines():
|
|
203
|
+
try:
|
|
204
|
+
row = json.loads(line)
|
|
205
|
+
imp = json.loads(row.get("impression") or "{}")
|
|
206
|
+
row["note_id"] = (((imp.get("noteTarget") or {}).get("value") or {}).get("noteId"))
|
|
207
|
+
if row.get("title") == title:
|
|
208
|
+
rows.append(row)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
if rows:
|
|
212
|
+
break
|
|
213
|
+
if not rows:
|
|
214
|
+
return None
|
|
215
|
+
newest = rows[0]
|
|
216
|
+
text = newest.get("text") or ""
|
|
217
|
+
status = "reviewing" if "审核中" in text else "rejected" if "未通过" in text else "published"
|
|
218
|
+
return {"note_id": newest.get("note_id"), "title": title, "status": status, "duplicate_count": len(rows), "card_text": text}
|
|
219
|
+
|
|
156
220
|
if CFG["action"] == "publish":
|
|
157
221
|
next_step("Publishing...")
|
|
158
|
-
|
|
222
|
+
# XHS can render the publish button before video processing is complete. A
|
|
223
|
+
# programmatic click on a disabled button is a no-op, so wait for the real
|
|
224
|
+
# control to become enabled before clicking it.
|
|
225
|
+
publish_result = "missing"
|
|
226
|
+
for attempt in range(180):
|
|
227
|
+
publish_result = js('var btn=[...document.querySelectorAll("button,[role=button]")].find(function(b){return b.offsetParent && (b.innerText||b.textContent||"").trim()==="发布"}) || document.querySelector(".publishBtn,.publish-btn,button.css-k01wfk,[class*=submit]");if(!btn){"missing"}else if(btn.disabled || btn.getAttribute("aria-disabled")==="true" || btn.classList.contains("disabled")){"disabled"}else{btn.scrollIntoView({block:"center"});btn.click();"clicked"}')
|
|
228
|
+
if publish_result == "clicked":
|
|
229
|
+
break
|
|
230
|
+
if attempt % 15 == 14:
|
|
231
|
+
print(f" -> Publish control {publish_result}; waiting for processing... ({attempt+1}s)")
|
|
232
|
+
time.sleep(1)
|
|
159
233
|
if publish_result != "clicked":
|
|
160
234
|
# XHS may expose the publish control only through the accessibility tree.
|
|
161
235
|
nodes = cdp("Accessibility.getFullAXTree")["nodes"]
|
|
162
236
|
target = next((n for n in nodes if (n.get("role") or {}).get("value") == "button" and (n.get("name") or {}).get("value") == "发布" and n.get("backendDOMNodeId")), None)
|
|
163
237
|
if target:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
238
|
+
resolved = cdp("DOM.resolveNode", backendNodeId=target["backendDOMNodeId"])["object"]
|
|
239
|
+
object_id = resolved.get("objectId")
|
|
240
|
+
if object_id:
|
|
241
|
+
cdp(
|
|
242
|
+
"Runtime.callFunctionOn",
|
|
243
|
+
objectId=object_id,
|
|
244
|
+
functionDeclaration="function(){ this.click(); return true; }",
|
|
245
|
+
returnByValue=True,
|
|
246
|
+
)
|
|
247
|
+
publish_result = "clicked"
|
|
248
|
+
if publish_result != "clicked":
|
|
170
249
|
print("ERROR: publish button not found")
|
|
171
250
|
capture_screenshot()
|
|
172
251
|
raise SystemExit(1)
|
|
173
|
-
|
|
252
|
+
redirect_verified = False
|
|
174
253
|
for _ in range(20):
|
|
175
254
|
time.sleep(1)
|
|
176
255
|
state = js('location.href.indexOf("publish/success")>=0 || /发布成功|成功发布/.test(document.body.innerText||"") ? "success" : "wait"')
|
|
177
256
|
if state == "success":
|
|
178
|
-
|
|
257
|
+
redirect_verified = True
|
|
179
258
|
break
|
|
259
|
+
receipt = creator_manager_receipt(CFG["title"])
|
|
260
|
+
if not receipt or not receipt.get("note_id"):
|
|
261
|
+
print("ERROR: publish was not verified in creator manager")
|
|
262
|
+
capture_screenshot()
|
|
263
|
+
raise SystemExit(2)
|
|
264
|
+
receipt["redirect_verified"] = redirect_verified
|
|
265
|
+
receipt["collection"] = collection or None
|
|
266
|
+
print("XHS_RECEIPT " + json.dumps(receipt, ensure_ascii=False, separators=(",", ":")))
|
|
267
|
+
verified = receipt["status"] in {"published", "reviewing"}
|
|
180
268
|
if not verified:
|
|
181
|
-
print("
|
|
269
|
+
print("ERROR: creator manager reports " + receipt["status"])
|
|
270
|
+
raise SystemExit(2)
|
|
182
271
|
else:
|
|
183
272
|
next_step("Saving as draft...")
|
|
184
273
|
# Find 暂存 button by unicode
|
|
@@ -2,8 +2,8 @@ platform: xhs
|
|
|
2
2
|
status: migrated_unverified
|
|
3
3
|
last_verified: null
|
|
4
4
|
actions:
|
|
5
|
-
post: actions/_post.py
|
|
6
|
-
manage: actions/_manage.py
|
|
5
|
+
post: "actions/_post.py"
|
|
6
|
+
manage: "actions/_manage.py"
|
|
7
7
|
verification:
|
|
8
8
|
status: migrated_unverified
|
|
9
9
|
last_verified: null
|
|
@@ -72,12 +72,22 @@ fields:
|
|
|
72
72
|
type: boolean
|
|
73
73
|
default: false
|
|
74
74
|
implemented: true
|
|
75
|
+
collection:
|
|
76
|
+
required: false
|
|
77
|
+
type: string
|
|
78
|
+
max_chars: 20
|
|
79
|
+
implemented: true
|
|
80
|
+
collection_description:
|
|
81
|
+
required: false
|
|
82
|
+
type: string
|
|
83
|
+
max_chars: 50
|
|
84
|
+
implemented: true
|
|
75
85
|
capabilities:
|
|
76
86
|
draft: true
|
|
77
87
|
publish: true
|
|
78
88
|
visibility: false
|
|
79
89
|
custom_thumbnail: false
|
|
80
|
-
series_or_collection:
|
|
90
|
+
series_or_collection: true
|
|
81
91
|
post_status: true
|
|
82
92
|
read_comments: true
|
|
83
93
|
write_comment: true
|
|
@@ -94,5 +104,4 @@ notes:
|
|
|
94
104
|
and use that card's live xsec detail route.
|
|
95
105
|
- The opened post detail view is the canonical place to read comments, write a top-level
|
|
96
106
|
comment, and reply to comments.
|
|
97
|
-
-
|
|
98
|
-
a prior manual browser-harness session created and attached a collection.
|
|
107
|
+
- Optional `collection` selects an existing exact-name collection or creates and joins it when absent.
|
|
@@ -15,3 +15,20 @@ def test_actions_use_current_tab_url_for_navigation_identity():
|
|
|
15
15
|
manage = (HERE / "actions" / "_manage.py").read_text()
|
|
16
16
|
assert '(current_tab().get("url") or "") != url' in post
|
|
17
17
|
assert '(current_tab().get("url") or "") != url' in manage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_video_readiness_uses_editor_state_not_preview():
|
|
21
|
+
source=(HERE/'actions'/'_post.py').read_text()
|
|
22
|
+
assert '上传中' in source
|
|
23
|
+
assert '重新上传' in source
|
|
24
|
+
assert 'Upload processed and editor-ready!' in source
|
|
25
|
+
|
|
26
|
+
def test_collection_and_receipt_contracts_are_present():
|
|
27
|
+
source=(HERE/'actions'/'_post.py').read_text()
|
|
28
|
+
manifest=(HERE/'manifest.yaml').read_text()
|
|
29
|
+
assert 'choose_collection' in source
|
|
30
|
+
assert 'creator_manager_receipt' in source
|
|
31
|
+
assert 'XHS_RECEIPT' in source
|
|
32
|
+
assert 'duplicate_count' in source
|
|
33
|
+
assert 'series_or_collection: true' in manifest
|
|
34
|
+
assert 'collection:' in manifest
|
|
@@ -12,11 +12,14 @@ def next_step(label):
|
|
|
12
12
|
print(f"[{step[0]}/{total_steps}] {label}")
|
|
13
13
|
|
|
14
14
|
def click_btn(text):
|
|
15
|
+
text_s = json.dumps(text)
|
|
15
16
|
for _ in range(5):
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
clicked = js(
|
|
18
|
+
'(function(){ var btns=document.querySelectorAll("ytcp-button"); '
|
|
19
|
+
'var b=Array.from(btns).find(function(el){return el.textContent.trim()===' + text_s + ' && !el.disabled;}); '
|
|
20
|
+
'if(!b)return false; b.click(); return true; })()'
|
|
21
|
+
)
|
|
22
|
+
if clicked:
|
|
20
23
|
return True
|
|
21
24
|
time.sleep(1)
|
|
22
25
|
return False
|
package/session_client.py
CHANGED
|
@@ -78,20 +78,30 @@ def ping():
|
|
|
78
78
|
try: return request({'op':'ping'},ensure=False)
|
|
79
79
|
except Exception: return None
|
|
80
80
|
|
|
81
|
+
def owner_is_running():
|
|
82
|
+
"""An exclusive owner lock means a live daemon already exists."""
|
|
83
|
+
owner_path=RUNTIME_DIR/'session.lock'
|
|
84
|
+
with open(owner_path,'a+b') as owner:
|
|
85
|
+
try:
|
|
86
|
+
fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
87
|
+
except BlockingIOError:
|
|
88
|
+
return True
|
|
89
|
+
finally:
|
|
90
|
+
try: fcntl.flock(owner,fcntl.LOCK_UN)
|
|
91
|
+
except OSError: pass
|
|
92
|
+
return False
|
|
93
|
+
|
|
81
94
|
def ensure_daemon():
|
|
82
95
|
p=ping()
|
|
83
96
|
if p and p.get('ok'): return p
|
|
84
97
|
RUNTIME_DIR.mkdir(parents=True,exist_ok=True)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
if owner_is_running():
|
|
99
|
+
raise RuntimeError('browser-workspace daemon is alive but its socket is unreachable; refusing duplicate startup')
|
|
100
|
+
runtime_python=ensure_runtime_python()
|
|
101
|
+
log=open(RUNTIME_DIR/'session-daemon.log','ab',buffering=0)
|
|
102
|
+
subprocess.Popen([str(runtime_python),str(SOURCE/'session_daemon.py')],stdin=subprocess.DEVNULL,stdout=log,stderr=log,start_new_session=True,close_fds=True)
|
|
103
|
+
deadline=time.time()+15
|
|
104
|
+
while time.time()<deadline:
|
|
105
|
+
time.sleep(.1); p=ping()
|
|
89
106
|
if p and p.get('ok'): return p
|
|
90
|
-
runtime_python=ensure_runtime_python()
|
|
91
|
-
log=open(RUNTIME_DIR/'session-daemon.log','ab',buffering=0)
|
|
92
|
-
subprocess.Popen([str(runtime_python),str(SOURCE/'session_daemon.py')],stdin=subprocess.DEVNULL,stdout=log,stderr=log,start_new_session=True,close_fds=True)
|
|
93
|
-
deadline=time.time()+15
|
|
94
|
-
while time.time()<deadline:
|
|
95
|
-
time.sleep(.1); p=ping()
|
|
96
|
-
if p and p.get('ok'): return p
|
|
97
107
|
raise RuntimeError(f'browser-workspace session daemon did not start; see {RUNTIME_DIR / "session-daemon.log"}')
|
package/session_daemon.py
CHANGED
|
@@ -21,7 +21,7 @@ from browser_harness import helpers as bh
|
|
|
21
21
|
DEFAULT_WORKSPACE = os.environ.get('BH_WORKSPACE_NAME','Harness')
|
|
22
22
|
RUNTIME_DIR = Path.home()/'.config/browser-workspace/runtime'
|
|
23
23
|
SOCKET_PATH = (Path(os.environ['BROWSER_WORKSPACE_SESSION_SOCKET']) if os.environ.get('BROWSER_WORKSPACE_TEST_MODE')=='1' and os.environ.get('BROWSER_WORKSPACE_SESSION_SOCKET') else RUNTIME_DIR/'session.sock')
|
|
24
|
-
OWNER_LOCK_PATH = RUNTIME_DIR/'session
|
|
24
|
+
OWNER_LOCK_PATH = RUNTIME_DIR/'session.lock'
|
|
25
25
|
SESSIONS = {}
|
|
26
26
|
SESSIONS_LOCK = threading.Lock()
|
|
27
27
|
BROWSER_LOCK = threading.RLock()
|
|
@@ -84,10 +84,13 @@ class Session:
|
|
|
84
84
|
return result
|
|
85
85
|
|
|
86
86
|
def _cdp(self, method, **kwargs):
|
|
87
|
+
if method == 'Target.createTarget':
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
'Target.createTarget is disabled in Browser Workspace sessions; '
|
|
90
|
+
'use new_tab(url) so the tab is owned by the active session/workspace'
|
|
91
|
+
)
|
|
87
92
|
result=bh.cdp(method, **kwargs)
|
|
88
|
-
if method == 'Target.
|
|
89
|
-
self._remember_target(result.get('targetId'))
|
|
90
|
-
elif method == 'Target.closeTarget':
|
|
93
|
+
if method == 'Target.closeTarget':
|
|
91
94
|
self.owned_target_ids.discard(kwargs.get('targetId'))
|
|
92
95
|
return result
|
|
93
96
|
|
|
@@ -253,12 +256,25 @@ def stop_session(sid):
|
|
|
253
256
|
if errors: release_error='; '.join(errors)
|
|
254
257
|
return {'session_id':sid,'workspace':s.workspace,'target_id':s.target_id,'closed_tabs':closed_count,'release_error':release_error}
|
|
255
258
|
|
|
259
|
+
SOCKET_IDENTITY = None
|
|
260
|
+
|
|
261
|
+
def remove_owned_socket():
|
|
262
|
+
"""Never unlink a socket replaced by another owner."""
|
|
263
|
+
if SOCKET_IDENTITY is None:
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
st = SOCKET_PATH.lstat()
|
|
267
|
+
if (st.st_dev, st.st_ino) == SOCKET_IDENTITY:
|
|
268
|
+
SOCKET_PATH.unlink()
|
|
269
|
+
except FileNotFoundError:
|
|
270
|
+
pass
|
|
271
|
+
|
|
256
272
|
def cleanup():
|
|
257
273
|
with SESSIONS_LOCK: ids=list(SESSIONS)
|
|
258
274
|
for sid in ids:
|
|
259
275
|
try: stop_session(sid)
|
|
260
276
|
except Exception: pass
|
|
261
|
-
try:
|
|
277
|
+
try: remove_owned_socket()
|
|
262
278
|
except Exception: pass
|
|
263
279
|
atexit.register(cleanup)
|
|
264
280
|
|
|
@@ -306,6 +322,7 @@ def serve_conn(conn):
|
|
|
306
322
|
f.write((json.dumps(result,ensure_ascii=False)+'\n').encode()); f.flush()
|
|
307
323
|
|
|
308
324
|
def main():
|
|
325
|
+
global SOCKET_IDENTITY
|
|
309
326
|
RUNTIME_DIR.mkdir(parents=True,exist_ok=True)
|
|
310
327
|
try: os.chmod(RUNTIME_DIR,0o700)
|
|
311
328
|
except Exception: pass
|
|
@@ -315,8 +332,9 @@ def main():
|
|
|
315
332
|
fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
316
333
|
except BlockingIOError:
|
|
317
334
|
owner.close(); return
|
|
335
|
+
# Lock ownership is required before changing the shared socket path.
|
|
318
336
|
SOCKET_PATH.unlink(missing_ok=True)
|
|
319
|
-
srv=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); srv.bind(str(SOCKET_PATH)); os.chmod(SOCKET_PATH,0o600); srv.listen(32)
|
|
337
|
+
srv=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); srv.bind(str(SOCKET_PATH)); os.chmod(SOCKET_PATH,0o600); SOCKET_IDENTITY=(SOCKET_PATH.stat().st_dev, SOCKET_PATH.stat().st_ino); srv.listen(32)
|
|
320
338
|
try:
|
|
321
339
|
while True:
|
|
322
340
|
conn,_=srv.accept(); threading.Thread(target=serve_conn,args=(conn,),daemon=True).start()
|
|
Binary file
|