Skip to content

v0.3.1: OpenCLI Browser Bridge + x-article + substack connectors#3

Merged
Nowhitestar merged 3 commits into
mainfrom
v0.3.1-browser-connectors
May 6, 2026
Merged

v0.3.1: OpenCLI Browser Bridge + x-article + substack connectors#3
Nowhitestar merged 3 commits into
mainfrom
v0.3.1-browser-connectors

Conversation

@Nowhitestar
Copy link
Copy Markdown
Owner

@Nowhitestar Nowhitestar commented May 6, 2026

Summary

P1b complete: real-browser draft creation for both X Articles and Substack via the user's real Chrome (driven by OpenCLI Browser Bridge). Both connectors verified end-to-end against real accounts, no automation detection trips, no separate logins.

Originally built on Playwright + fresh Chromium. Pivoted to OpenCLI mid-PR after Google OAuth detection blocked verification — see commit 6ad2010 for the rationale and migration.

Three-platform demo ✅

Single mmp publish examples/longform-multi.yaml --mode-override draft hit all three providers, all returned status=ok with real external IDs:

wechat-article: WbX2nHWvJ4Nnr7szvvzYx_6hRvH0gF... (WeChat OA API)
x-article:      2051997319453929472              (X.com via OpenCLI)
substack:       196650004                        (Substack via OpenCLI)

mmp's actual user value end-to-end: one publish, three platforms, all draft-platform with real IDs. Verified against real WeChat OA + X Premium + Substack publication accounts.

What's in this PR

Foundation (core/browser.py)

  • Wraps opencli browser subcommands as Python primitives: open_url, state, get_url, get_title, click, type_text, wait, evaluate, screenshot, find, is_connected, doctor
  • Lazy npx -y @jackwener/opencli invocation if the user doesn't have a global install
  • Three typed errors: BrowserNotInstalledError, BrowserNotConnectedError, BrowserCommandError
  • 11 unit tests, mocked subprocess (no opencli required to run tests)

Provider abstraction (core/provider.Provider.browser_login_url)

  • Optional class attr; providers that authenticate via browser session set it
  • mmp browser login <provider> opens that URL in the user's Chrome

CLI (mmp browser)

  • mmp browser status — extension connectivity check
  • mmp browser login <provider> — opens provider's login URL
  • mmp browser doctor — full opencli diagnostic

x-article connector

  • providers/x_article/internal/browser_flow.py — drives X Articles real flow
  • 7 tests (stub fallback, mocked browser flow, health_check, browser_login_url)
  • Verified: created real X Article draft 2051993962379132929

substack connector

  • providers/substack/internal/browser_flow.py — drives Substack's post composer
  • Publication URL resolution: target.options → SUBSTACK_PUBLICATION_URL env → stub
  • 9 tests
  • Verified: created real Substack draft 196650004 on lewisxbt.substack.com

Docs

  • docs/browser-connectors.md rewritten — OpenCLI setup, troubleshooting, selector-drift playbook, i18n caveats, security model

Bugs found & fixed during real-account verification

# Bug Fix
1 Playwright Chromium triggered Google OAuth anti-bot block Pivoted entire backend to OpenCLI Browser Bridge
2 /i/articles/compose returns 404 (even for X Premium) Real URL is /compose/articles
3 [data-testid="articleTitle"] doesn't exist in current X UI Real title field is textarea[placeholder="添加标题"]
4 [data-testid="tweetTextarea_0"] matches 2 elements (modal + inline) Scope with div[role="dialog"] prefix
5 browser state returns text not JSON; URL field empty Added get_url() / get_title() helpers
6 X Articles has no Save button — autosave only Removed unnecessary save click
7 i18n: Chinese UI uses 添加标题 placeholder Title selector candidates list with English fallback
8 X Articles requires Premium / Premium+; free users get redirected Provider falls back to stub with clear remediation
9 Substack /publish/post redirects to "Discover Newsletters" without publication URL Resolve from manifest options or env; stub if missing

Test Plan

  • make test — lint + format + mypy + 100+ tests + smoke
  • Real-account verification on X Premium ✅ (draft 2051993962379132929)
  • Real-account verification on Substack ✅ (draft 196650004)
  • Three-platform combined run ✅ (wechat + x + substack all status=ok in one publish)
  • CI matrix green (verifies on push)

Backward compat

Zero impact for v0.3.0 users:

  • OpenCLI extension is opt-in
  • Without it, browser-flow providers gracefully fall back to "stub" mode (TODO-connector.md with manual steps), exactly as before
  • Substack manifests now need publication_url — but those without it just stay in stub mode (same as v0.3.0)

Out of this PR

  • Cover image upload across browser-flow providers (v0.4)
  • Session expiry auto-detection (v0.4)
  • Long-form X posts (Premium without Premium+) — separate provider x-post if interest
  • Contribute reusable Substack/X article-create adapters back to OpenCLI upstream (v0.4)

🤖 Generated with Claude Code

## What

Real-browser draft creation for providers that have no public API
(starting with X Articles; Substack lands in PR-B). Built on Playwright
as an opt-in extra.

## Components

### `core/browser.py` — session manager (foundation)

- `BrowserNotInstalledError` / `BrowserStateMissingError` (both `MMPError`)
- `state_path(provider)` / `state_exists(provider)` / `delete_state(provider)`
- `browser_context(provider, headless=True, require_state=True)` — context
  manager yielding a logged-in `BrowserContext`
- `login_interactive(provider, login_url)` — headed flow, captures state
  and persists to `~/.config/mmp/browser-state/<provider>.json` (chmod 600)
- Lazy import: missing playwright surfaces as `BrowserNotInstalledError`
  with install instructions, not at module import time

### `Provider.browser_login_url` (optional class attr)

Providers that authenticate via browser session set this. Used by
`mmp browser login <name>` to know where to navigate.

### `mmp browser` CLI subcommand

```bash
mmp browser login <provider>   # headed flow, capture session
mmp browser status [provider]  # list saved states with mtime
mmp browser logout <provider>  # delete state
```

### `providers/x_article/` upgraded

- `browser_login_url = "https://x.com/i/flow/login"`
- `internal/browser_flow.py` — actual Playwright draft flow (selectors
  isolated as module-top constants for easy patching when X drifts UI)
- `provider.execute(mode="draft")`:
  - if no state OR no playwright → `mode_actual="stub"`, writes
    `TODO-connector.md` with both setup and manual fallback paths
  - if both available → `mode_actual="draft-platform"` with real X
    article ID as `external_id`

### `docs/browser-connectors.md`

Setup, session lifecycle, troubleshooting, security guidance, selector
drift remediation, roadmap.

## Tests

- `tests/core/test_browser.py` — 9 unit tests for browser module (state
  paths, missing-state errors, install hint, chmod 600 on save)
- `tests/integration/test_browser_cli.py` — 7 tests for `mmp browser`
  subcommand (status, logout, login validation)
- `providers/x_article/tests/test_provider.py` — 4 new tests (stub
  fallback when no state; mocked browser flow returns draft-platform;
  health_check reflects state; browser_login_url is set)

All tests pass without playwright installed (the foundation is testable
with mocks). Real-browser tests are gated on the `[browser]` extra +
real X account; documented in `docs/manual-verification.md` (next PR).

## Out of this PR

- Substack connector (PR-B; same pattern, different selectors)
- Real-account verification of x-article (depends on user with X account
  capturing login state)
- Selector drift detection / auto re-login prompt (v0.4)

## Migration

Zero-impact for v0.3.0 users. The `[browser]` extra is opt-in. Without
it, x-article keeps returning `mode_actual="stub"` exactly as before
v0.3.1, just with a more helpful TODO-connector.md.
Real-account verification of x-article hit two showstoppers with the
Playwright approach:

1. Google OAuth (sign-in path on X) detected Playwright Chromium and
   refused login. The user couldn't sign in via the fresh Chromium.
2. CDP-attach to the user's real Chrome required re-launching Chrome
   with `--remote-debugging-port` AND a non-default `--user-data-dir`
   (Chrome refuses CDP on the default profile). Heavy friction.

Pivot to OpenCLI (https://github.com/jackwener/opencli): a Chrome
extension + Node.js daemon that exposes browser primitives via
`opencli browser ...`. The extension drives the user's real Chrome —
no automation detection trips, no separate session, no Chrome relaunch.

## Architecture

- `core/browser.py` — wraps `opencli browser` subcommands as a Python
  module. Functions: `open_url`, `state`, `get_url`, `get_title`,
  `click`, `type_text`, `wait`, `evaluate`, `screenshot`, `find`,
  `is_connected`, `doctor`. Lazy `npx -y @jackwener/opencli` invocation
  if `opencli` not on PATH.

- `core/provider.Provider.browser_login_url` — informational anchor for
  `mmp browser login <provider>`; opens that URL in Chrome (the user
  logs in normally, mmp doesn't manage state).

- `mmp browser` CLI subcommand:
  - `mmp browser status` — extension connectivity check
  - `mmp browser login <provider>` — opens provider's login URL
  - `mmp browser doctor` — full opencli diagnostic

- `providers/x_article/internal/browser_flow.py` — drives X Articles
  compose flow:
  1. Navigate to `x.com/compose/articles` (drafts list)
  2. Click "Write" / "撰写" → X allocates draft, URL becomes
     `/compose/articles/edit/<draft-id>`
  3. Capture draft_id from URL (= our `external_id`)
  4. Type title into `textarea[placeholder="添加标题"]` (with English
     fallback `Add a title`)
  5. Type body into `[data-testid="composer"]`
  6. X auto-saves; we sleep 4s to let it land

- `providers/x_article/provider.execute(draft)` — calls `is_connected()`,
  falls back to "stub" mode (writes TODO-connector.md) if bridge
  not connected. Real flow returns `mode_actual="draft-platform"`.

## Real-account verification

Verified end-to-end against Lewis's X Premium account:
```
mode_actual: draft-platform
external_id: 2051993962379132929
draft_url:   https://x.com/compose/articles/edit/2051993962379132929
```

Draft confirmed in X drafts list.

## Bugs found & fixed during verification

1. v0.2 Playwright path tried `[data-testid="articleTitle"]` — X has
   no such element. The articles compose UI is at `/compose/articles`
   not `/i/articles/compose` (the latter returns 404 even for Premium).

2. `[data-testid="tweetTextarea_0"]` matched 2 elements (modal +
   inline timeline composer). Scoped with `div[role="dialog"] ` prefix.

3. `browser state` emits text not JSON; URL field was always empty.
   Added `get_url()` / `get_title()` helpers using `browser get url`
   which returns plain text.

4. X Articles compose has no "Save Draft" button — autosave fires
   while typing. Removed unnecessary save-button click.

## Removed

- `pyproject.toml [project.optional-dependencies] browser = [playwright]`
  is gone. No Python deps for browser flow now (opencli is Node.js).
- `playwright` mypy override (no longer needed).
- All Playwright-specific code in `core/browser.py` (login_interactive
  with stdin/auto-detect/CDP modes, BrowserStateMissingError, etc).

## Documentation

`docs/browser-connectors.md` rewritten end-to-end:
- OpenCLI install + extension setup
- Setup verification flow
- Selector-drift remediation playbook
- i18n caveats (selectors target Chinese UI; English fallback included)
- Security model (cookies stay in user's Chrome)
- Roadmap (v0.3.2 substack, v0.4 session expiry detection)
Same OpenCLI Browser Bridge backend as x-article, applied to Substack.
First-shot verification (no selector debugging needed) — the abstraction
from PR-A carried over cleanly.

## Architecture

- `providers/substack/internal/browser_flow.py` — drives Substack's
  post composer:
  1. Navigate to `<publication>.substack.com/publish/post`
  2. Substack auto-allocates draft, URL becomes
     `/publish/post/<draft-id>` (numeric)
  3. Capture draft_id from URL → `external_id`
  4. Type title into `[data-testid="post-title"]`
  5. Type subtitle (optional) into `input[placeholder="Add a subtitle…"]`
     with English fallback `Add a subtitle...` (no Unicode ellipsis)
  6. Type body into `[data-testid="editor"]` (contenteditable)
  7. Substack autosave; sleep 4s

- `providers/substack/provider.py` — orchestration mirrors x-article
  with one Substack-specific addition: **publication URL discovery**.
  Each Substack writer has a unique subdomain (e.g.
  `lewisxbt.substack.com`); we resolve it via:
    1. Manifest target's `options.publication_url`
    2. `SUBSTACK_PUBLICATION_URL` env var
    3. Stub fallback with clear remediation message
  No universal `substack.com/publish/post` URL works for arbitrary
  users (it redirects to "Discover Substack Newsletters").

## Real-account verification ✅

Tested end-to-end against Lewis's Substack publication
`lewisxbt.substack.com`:

```
mode_actual: draft-platform
external_id: 196650004
draft_url:   https://lewisxbt.substack.com/publish/post/196650004
```

Draft confirmed in Substack publish dashboard.

## Three-platform demo run ✅

Single `mmp publish examples/longform-multi.yaml --mode-override draft`
hit all three providers, all returned status=ok with real external IDs:

```
wechat-article: WbX2nHWvJ4Nnr7szvvzYx_6hRvH0gF526sLiaJJIHHXM-UPSfevgQ3GpIxsPvpDY (WeChat OA API)
x-article:      2051997319453929472    (X.com via OpenCLI)
substack:       196650004              (Substack via OpenCLI)
```

This is multi-media-publisher's first complete cross-platform draft run
end-to-end against real accounts.

## Tests

`providers/substack/tests/test_provider.py`: 9 tests
- `test_validate_passes`
- `test_validate_subtitle_too_long`
- `test_prepare_writes_payload`
- `test_execute_draft_stub_when_no_publication_url`
- `test_execute_draft_stub_when_bridge_disconnected`
- `test_execute_draft_uses_publication_url_from_target_options`
  (manifest options take priority over env var)
- `test_execute_draft_invokes_browser_flow`
- `test_health_check_reflects_bridge`
- `test_browser_login_url_is_set`
- `test_execute_publish_refused`

## Backward compat

Manifests using substack as a target need a `publication_url` now
(via target options or env). Without it, fall back to stub mode with
a clear error. Existing v0.2/v0.3.0 manifests that didn't have
publication_url set continue to function (just stay in stub mode
exactly as they did before).
@Nowhitestar Nowhitestar changed the title v0.3.1 PR-A: browser-flow connectors foundation + x-article v0.3.1: OpenCLI Browser Bridge + x-article + substack connectors May 6, 2026
@Nowhitestar Nowhitestar merged commit 7dd6849 into main May 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant