Skill: bluebluebbb/gt-core-skill (version 1.0.0)

--- name: gumtree-skills description: | Use this skill when the user needs to work with Gumtree in a real Chrome browser session. It runs the local Python CLI and Chrome extension bridge to: - check login state - log in or out - search listings - read home recommendations, favourites, detail pages, and messages - favourite a listing - start the post-ad category flow --- # Gumtree Skills Use this skill only for Gumtree tasks that must run in a real browser via this repository's bridge. ## When To Use - The user wants to inspect Gumtree content from a live browser session instead of plain HTTP scraping. - The user wants to check login state, log in, log out, search listings, open favourites, read a detail page, send a Gumtree message, or start the post-ad flow. - The user already has or can provide a Gumtree listing URL, keyword, login credentials, message text, or post-ad keyword. ## What This Skill Does - Runs Gumtree actions through `uv run python scripts/cli.py ...`. - Uses a local WebSocket bridge server plus the Chrome extension in `extension/`. - Returns structured JSON from the CLI and should preserve key result fields in the final answer. ## Hard Boundaries - Do not use unofficial alternate Gumtree automation projects or invent nonexistent Gumtree APIs. - Do not run commands with system `python` or a random virtualenv; use `uv run`. - Do not claim support for features outside the implemented commands. - Current scope stops at category selection for `post-ad`; filling and submitting the final ad form is not implemented. ## Setup And Environment Work from the directory that contains `SKILL.md`, `pyproject.toml`, `uv.lock`, `scripts/`, and `extension/`. Prerequisites: - Python 3.11+ - `uv` - Google Chrome First-time setup: 1. Run `uv sync`. 2. Open `chrome://extensions/`. 3. Enable Developer Mode. 4. Load the unpacked extension from `extension/`. 5. Confirm `Gumtree Bridge` is enabled. Runtime notes: - The CLI automatically checks and starts the local bridge server if needed. - If the extension is not connected, the CLI may open Chrome and wait for the extension bridge. - If you see `No module named 'websockets'`, run from this project directory and use `uv run` after `uv sync`. ## Command Routing Use these commands exactly from the project root: - Check login: `uv run python scripts/cli.py check-login` - Login: `uv run python scripts/cli.py login --username "<email>" --password "<password>"` - Logout: `uv run python scripts/cli.py logout` - Search: `uv run python scripts/cli.py search --keyword "<keyword>"` - Home recommendations: `uv run python scripts/cli.py home-recommend` - Favourites: `uv run python scripts/cli.py favourites` - Detail page: `uv run python scripts/cli.py detail --url "<detail-url>"` - Favourite detail page: `uv run python scripts/cli.py detail-favourite --url "<detail-url>"` - Messages: `uv run python scripts/cli.py messages` - Detail message flow: `uv run python scripts/cli.py detail-message --url "<detail-url>"` - Post ad category flow: `uv run python scripts/cli.py post-ad --keyword "<keyword>"` Installed entrypoint variants are also valid: - `uv run gumtree-skills ...` ## Search And Filter Rules For `search`, add optional flags only when the user asks for filtering or sorting: - `--limit <N>` - `--search-location <location>` - `--search-category <category>` - `--sort relevance|date|price_lowest_first|price_highest_first|distance` - `--distance <N>` - `--min-price <N>` - `--max-price <N>` - `--condition as_good_as_new|good|new|fair` and repeat when needed - `--seller-type trade|private` and repeat when needed - `--mobile-storage-capacity <value>` - `--common-for-sale-colour <value>` - `--mobile-model-apple <value>` Preserve Gumtree's default search behavior unless the user asks to constrain it. ## Feature-Specific Notes ### Login State - `check-login` should treat page-embedded JS data as the primary source of truth. - `__GUMTREE_ANALYTICS_CONFIG__`, `initialDataLayer`, `gumtreeDataLayer`, `legacy.loggedIn`, and `window.clientData` are stronger signals than DOM text. - DOM labels such as `Manage my Ads`, `My Orders`, `Favourites`, `My Alerts`, `My Details`, `Login`, and `Sign up` are only fallbacks. ### Authentication - `login` uses the page login modal, switches to email login, then submits the email and password. - Credential arguments appear in shell history; avoid unnecessary repetition in summaries. - `logout` uses the user menu and submits `logout-form`. ### Favourites - `favourites` opens `https://www.gumtree.com/my-account/favourites`. - The extractor prefers `window.clientData.favouriteAds.adverts`. - If the user is not logged in, Gumtree may redirect to login or return empty results. ### Detail Favourite - `detail-favourite` requires a logged-in session. - It checks favourite state before and after clicking and may return flags such as `already_favourited` or `just_favourited`. ### Messages - `messages` opens `https://www.gumtree.com/manage/messages`. - Add `--conversation-id "<id>"` to target a specific conversation. - Add `--message "<text>"` to send a message in the current or selected conversation. - `detail-message` first enters messaging from the listing detail page, then reads or sends messages. - `messages` and `detail-message` both require a logged-in session. ### Post Ad - `post-ad` calls Gumtree's category suggestion API with the provided keyword. - With only `--keyword`, it returns suggested categories for user confirmation. - Add `--category-name "<name>"` for fuzzy name matching. - Add `--category-index <index>` to pick a suggestion directly. - After category selection it navigates to `/postad/create?categoryId=<id>`. ## Response Handling - The CLI prints JSON and exits with `0` on success or `2` on failure. - Keep the JSON structure intact when possible and summarize only the most important fields. - If a command fails, report the CLI error instead of fabricating results. ## Known Limitations - No session import mode. - No official Gumtree API mode. - No favourite sync or general favourite management beyond `detail-favourite`. - `post-ad` stops after category selection and redirect. - All content extraction depends on the live browser DOM and embedded page data, so site changes may require script updates.

path: pyproject.toml
content: [build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "gumtree-skills"
version = "0.1.0"
description = "Gumtree 自动化 Skills，基于 Chrome Extension Bridge 浏览器自动化"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
dependencies = [
    "websockets>=12.0",
]

[project.scripts]
gumtree-skills = "gumtree_skills.cli:main"

[tool.setuptools]
include-package-data = true

[tool.setuptools.packages.find]
include = ["gumtree_skills*", "scripts*"]


path: README.md
content: # gumtree-skills

Gumtree 自动化 Skills，通过 Chrome 扩展 + Python 引擎，让 AI Agent 以真实用户身份访问 Gumtree。

当前支持十一类能力：

- 检查 Gumtree 当前登录状态
- 通过邮箱和密码登录 Gumtree
- 退出 Gumtree 当前登录状态
- 搜索 Gumtree 列表内容
- 提取 Gumtree 首页推荐内容
- 提取 Gumtree 收藏内容
- 提取 Gumtree 详情页内容
- 收藏 Gumtree 详情页
- 查看 Gumtree 站内交流页
- 从详情页点击 Message 进入站内交流并发送消息
- 发布二手物品（选择类目并跳转到发布详情页）

## 安装

### 前置条件

- Python >= 3.11
- [uv](https://docs.astral.sh/uv/) 包管理器
- Google Chrome 浏览器

### 第一步：安装项目

把整个 `gumtree-skills/` 目录放到你的 skills 目录中，或直接在本地仓库中使用。

安装 Python 依赖：

```bash
cd gumtree-skills
uv sync
```

### 第二步：安装浏览器扩展

1. 打开 Chrome，地址栏输入 `chrome://extensions/`
2. 右上角开启开发者模式
3. 点击加载已解压的扩展程序，选择本项目的 `extension/` 目录
4. 确认扩展 `Gumtree Bridge` 已启用

## 使用方式

### 作为 AI Agent 技能使用

安装到 skills 目录后，直接用自然语言与 Agent 对话即可。

示例：

> "在 Gumtree 搜索 iphone"

> "帮我看看 Gumtree 首页推荐"

> "抓一下这个 Gumtree 帖子详情"

> "检查一下我现在在 Gumtree 是不是登录状态"

### 作为 CLI 工具使用

所有功能也可以通过命令行直接调用，输出 JSON 格式。

```bash
# 检查当前登录状态
uv run python scripts/cli.py check-login

# 邮箱密码登录
uv run python scripts/cli.py login --username "[REDACTED_SECRET]" --password "your-password"

# 退出当前登录状态
uv run python scripts/cli.py logout

# 搜索列表
uv run python scripts/cli.py search --keyword "iphone" --limit 10

# 带排序和筛选
uv run python scripts/cli.py search \
  --keyword "iphone" \
  --search-location "Sandwell" \
  --search-category "iphone" \
  --distance 3 \
  --sort date \
  --min-price 10 \
  --max-price 10000 \
  --condition as_good_as_new \
  --condition good \
  --seller-type trade \
  --seller-type private

# 首页推荐
uv run python scripts/cli.py home-recommend --limit 10

# 收藏列表
uv run python scripts/cli.py favourites --limit 10

# 详情页
uv run python scripts/cli.py detail --url "https://www.gumtree.com/p/..."

# 收藏详情页
uv run python scripts/cli.py detail-favourite --url "https://www.gumtree.com/p/..."

# 站内交流页
uv run python scripts/cli.py messages

# 指定会话并发送消息
uv run python scripts/cli.py messages --conversation-id [REDACTED_SECRET] --message "Hi, is this still available?"

# 从详情页点击 Message 并发送消息
uv run python scripts/cli.py detail-message --url "https://www.gumtree.com/p/..." --message "Hi, is this still available?"

# 发布二手物品 —— 查看建议类目
uv run python scripts/cli.py post-ad --keyword "iPhone"

# 发布二手物品 —— 按类目名称选择并跳转
uv run python scripts/cli.py post-ad --keyword "iPhone" --category-name "Apple iPhone"

# 发布二手物品 —— 按索引选择并跳转（选第一个建议类目）
uv run python scripts/cli.py post-ad --keyword "iPhone" --category-index 0
```

> 不要直接使用系统 `python` 或当前 shell 的 `conda` 环境运行脚本；推荐始终通过 `uv run` 启动。

> CLI 会自动检查并启动本地 bridge server；如果扩展未连接，会尝试打开 Chrome 并等待 `Gumtree Bridge` 扩展连接。首次使用前仍需要先在 Chrome 中加载扩展。

如果你希望使用安装后的命令入口，也可以运行：

```bash
uv run gumtree-skills check-login
uv run gumtree-skills login --username "[REDACTED_SECRET]" --password "your-password"
uv run gumtree-skills logout
uv run gumtree-skills search --keyword "iphone" --limit 10
uv run gumtree-skills home-recommend --limit 10
uv run gumtree-skills favourites --limit 10
uv run gumtree-skills detail --url "https://www.gumtree.com/p/..."
uv run gumtree-skills detail-favourite --url "https://www.gumtree.com/p/..."
uv run gumtree-skills messages
uv run gumtree-skills messages --conversation-id [REDACTED_SECRET] --message "Hi, is this still available?"
uv run gumtree-skills detail-message --url "https://www.gumtree.com/p/..." --message "Hi, is this still available?"
uv run gumtree-skills post-ad --keyword "iPhone"
uv run gumtree-skills post-ad --keyword "iPhone" --category-name "Apple iPhone"
uv run gumtree-skills post-ad --keyword "iPhone" --category-index 0
```

如果看到 `No module named 'websockets'`，通常说明当前命令不是通过 `uv run` 启动，或还没有在项目目录执行 `uv sync`。

搜索参数说明：

- `--sort` 支持 `relevance`、`date`、`price_lowest_first`、`price_highest_first`、`distance`。
- `--condition` 支持 `as_good_as_new`、`good`、`new`、`fair`，可重复传入。
- `--seller-type` 支持 `trade`、`private`，可重复传入。
- 价格、距离和类别筛选分别使用 `--min-price`、`--max-price`、`--distance`、`--search-category`。

登录态检查说明：

- `check-login` 优先使用页面内埋点对象判断登录态，包括 `window.__GUMTREE_ANALYTICS_CONFIG__`、`initialDataLayer.u.li`、`window.gumtreeDataLayer[0].user.loggedInStatus`、`legacy.loggedIn` 和 `window.clientData.userData`。
- DOM 中的 `Manage my Ads`、`My Orders`、`Favourites`、`My Alerts`、`My Details` 与 `Login`、`Sign up` 只作为辅助兜底信号。
- 输出会返回 `logged_in`；如果已登录，还会返回 `account` 和 `menu_links`。

登录说明：

- `login` 会自动打开 Gumtree 登录弹窗，切换到 `Continue with email`，填入邮箱和密码后提交。
- 当前命令参数会直接出现在 shell 历史里；如果是共享机器，建议用临时终端会话执行。
- `logout` 会自动展开用户菜单并提交 `logout-form`。

收藏页说明：

- `favourites` 会打开 `https://www.gumtree.com/my-account/favourites`，优先从 `window.clientData.favouriteAds.adverts` 提取收藏列表。
- 输出包含收藏总数、用户信息，以及每条收藏的标题、价格、位置、链接、图片、发布时间和商品属性。
- 如果当前未登录，Gumtree 可能会重定向到登录流程，此时命令会返回提取失败或空结果。

详情页收藏说明：

- `detail-favourite` 会打开指定详情页，定位详情区域的 `Favourite` 按钮并执行点击。
- 当前实现会先检查登录状态，再综合 `window.clientData.sellerActions.savedAds`、按钮 `is-saved` class、`data-q=full-heart`/`filled-heart` 和粉色心形图标状态判断点击前后收藏是否成功。
- 如果帖子已收藏，会返回 `already_favourited: true`；如果刚刚收藏成功，会返回 `just_favourited: true`。

站内交流说明：

- `messages` 会打开 `https://www.gumtree.com/manage/messages`，提取左侧会话列表、当前会话信息，以及当前会话中的消息文本和时间。
- 传 `--conversation-id` 时，会直接打开指定会话；传 `--message` 时，会在当前会话填写并发送消息。
- `detail-message` 会先打开详情页，点击卖家联系区域的 `Message` 按钮，等待进入站内交流页后再提取会话或发送消息。
- 这两个命令都需要用户已登录 Gumtree。

发布物品说明：

- `post-ad` 通过调用 Gumtree 类目建议 API（`https://my.gumtree.com/api/category/suggest?input=<keyword>`）获取建议类目。
- 仅传 `--keyword` 时，返回所有建议类目供用户选择；传 `--category-name` 或 `--category-index` 可自动选择类目并跳转到发布详情页（`/postad/create?categoryId=<id>`）。
- 需要用户已登录 Gumtree。

## 退出码

- `0` 成功
- `2` 错误

## 项目结构

```text
gumtree-skills/
├── extension/
│   ├── manifest.json
│   ├── background.js
│   └── content.js
├── gumtree_skills/
│   ├── __init__.py
│   ├── __main__.py
│   └── cli.py
├── scripts/
│   ├── bridge_server.py
│   ├── cli.py
│   └── gumtree/
│       ├── __init__.py
│       ├── bridge.py
│       ├── browser_detail.py
│       ├── browser_detail_favourite.py
│       ├── browser_favourites.py
│       ├── browser_home.py
│       ├── browser_messages.py
│       ├── browser_post_ad.py
│       ├── browser_search.py
│       ├── browser_urls.py
│       ├── errors.py
│       └── selectors.py
├── SKILL.md
├── CLAUDE.md
├── README.md
├── pyproject.toml
└── uv.lock
```

## 已知限制

- 当前不支持收藏同步、增删收藏或官方 API 模式；发布功能仅完成到类目选择和跳转，后续填写表单待实现
- 搜索、首页、收藏和详情页都依赖真实浏览器环境，不是纯 HTTP 抓取
- 主数据优先取结构化数据和页面内嵌数据，页面改版后可能需要更新脚本


path: CLAUDE.md
content: # gumtree-skills

Gumtree 自动化 Claude Code Skills，通过用户真实浏览器访问 Gumtree。

## 开发命令

```bash
uv sync
uv run python scripts/cli.py --help
```

## 架构

双层结构：`scripts/` 是 Python 自动化引擎，`SKILL.md` 负责技能路由和调用约束。

- `scripts/gumtree/` - 核心浏览器桥接与页面提取逻辑
- `scripts/cli.py` - 统一 CLI 入口，JSON 结构化输出
- `scripts/bridge_server.py` - 本地通信服务
- `extension/` - Chrome 扩展
- `gumtree_skills/cli.py` - 包入口，复用同一套命令实现

### 调用方式

```bash
uv run python scripts/cli.py check-login
uv run python scripts/cli.py login --username "[REDACTED_SECRET]" --password "your-password"
uv run python scripts/cli.py logout
uv run python scripts/cli.py search --keyword "iphone"
uv run python scripts/cli.py search --keyword "iphone" --search-location "Sandwell" --sort date --min-price 10 --max-price 10000
uv run python scripts/cli.py home-recommend
uv run python scripts/cli.py detail --url "https://www.gumtree.com/p/..."
uv run python scripts/cli.py post-ad --keyword "iPhone"
uv run python scripts/cli.py post-ad --keyword "iPhone" --category-name "Apple iPhone"
uv run python scripts/cli.py post-ad --keyword "iPhone" --category-index 0
```

- 不要假设当前 shell 的 `python` 就是项目环境；统一通过 `uv run` 调用。
- CLI 会自动检查并拉起 `scripts/bridge_server.py`，并在扩展未连接时尝试打开 Chrome。
- `check-login` 优先使用页面内数据对象判断登录态，DOM 只做兜底。
- `login` 通过页面弹窗执行邮箱密码登录，当前实现依赖 `hm-login`、`email-login` 和邮箱登录表单结构。
- `logout` 通过用户菜单中的 `logout-form` 执行退出。
- `post-ad` 通过 `https://my.gumtree.com/api/category/suggest` 获取建议类目，选中后导航到 `/postad/create?categoryId=<id>`。

## 代码规范

- 行长度上限 100 字符
- JSON 输出使用 `ensure_ascii=False`
- CLI exit code: `0=成功`, `2=错误`


path: scripts/__init__.py
content: """scripts package for gumtree-skills."""


path: scripts/cli.py
content: """Backward-compatible wrapper for gumtree-skills CLI."""

from __future__ import annotations

import sys
from pathlib import Path

if sys.stdout and hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
    sys.stderr.reconfigure(encoding="utf-8")


def main() -> None:
    project_root = Path(__file__).resolve().parent.parent
    if str(project_root) not in sys.path:
        sys.path.insert(0, str(project_root))
    from gumtree_skills.cli import main as package_main

    package_main()


if __name__ == "__main__":
    main()


path: scripts/bridge_server.py
content: """Gumtree Extension Bridge Server."""

from __future__ import annotations

import argparse
import asyncio
import json
import logging
import sys
import uuid
from typing import Any

import websockets
from websockets.server import ServerConnection

logger = logging.getLogger("gumtree-bridge")


class BridgeServer:
    def __init__(self) -> None:
        self._extension_ws: ServerConnection | None = None
        self._pending: dict[str, asyncio.Future[Any]] = {}

    async def handle(self, ws: ServerConnection) -> None:
        try:
            raw = await asyncio.wait_for(ws.recv(), timeout=10)
        except (asyncio.TimeoutError, Exception) as exc:
            logger.warning("握手超时或失败: %s", exc)
            return

        try:
            msg = json.loads(raw)
        except json.JSONDecodeError:
            return

        role = msg.get("role")
        if role == "extension":
            await self._handle_extension(ws)
        elif role == "cli":
            await self._handle_cli(ws, msg)
        else:
            logger.warning("未知 role: %s", role)

    async def _handle_extension(self, ws: ServerConnection) -> None:
        logger.info("Extension 已连接")
        self._extension_ws = ws
        try:
            async for raw in ws:
                try:
                    msg = json.loads(raw)
                except json.JSONDecodeError:
                    continue
                msg_id = msg.get("id")
                if msg_id and msg_id in self._pending:
                    future = self._pending.pop(msg_id)
                    if not future.done():
                        future.set_result(msg)
        finally:
            self._extension_ws = None
            logger.info("Extension 已断开")
            for future in self._pending.values():
                if not future.done():
                    future.set_exception(ConnectionError("Extension 断开连接"))
            self._pending.clear()

    async def _handle_cli(self, ws: ServerConnection, msg: dict[str, Any]) -> None:
        if msg.get("method") == "ping_server":
            await ws.send(
                json.dumps(
                    {"result": {"extension_connected": self._extension_ws is not None}},
                    ensure_ascii=False,
                )
            )
            return

        if not self._extension_ws:
            await ws.send(
                json.dumps(
                    {"error": "Extension 未连接，请确认浏览器已安装并启用 Gumtree Bridge 扩展"},
                    ensure_ascii=False,
                )
            )
            return

        msg_id = str(uuid.uuid4())
        msg["id"] = msg_id
        loop = asyncio.get_event_loop()
        future: asyncio.Future[Any] = loop.create_future()
        self._pending[msg_id] = future

        await self._extension_ws.send(json.dumps(msg, ensure_ascii=False))

        try:
            result = await asyncio.wait_for(future, timeout=90.0)
            await ws.send(json.dumps(result, ensure_ascii=False))
        except asyncio.TimeoutError:
            self._pending.pop(msg_id, None)
            await ws.send(json.dumps({"error": "命令执行超时（90s）"}, ensure_ascii=False))
        except ConnectionError as exc:
            await ws.send(json.dumps({"error": str(exc)}, ensure_ascii=False))


async def main(port: int) -> None:
    server = BridgeServer()
    async with websockets.serve(server.handle, "localhost", port):
        logger.info("Bridge server 已启动: ws://localhost:%d", port)
        logger.info("等待浏览器扩展连接...")
        await asyncio.Future()


if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )
    if sys.stdout and hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")

    parser = argparse.ArgumentParser(description="Gumtree Extension Bridge Server")
    parser.add_argument("--port", type=int, default=9335, help="监听端口（默认 9335）")
    args = parser.parse_args()

    asyncio.run(main(args.port))


path: scripts/gumtree/browser_post_ad.py
content: from __future__ import annotations

import json
import time
from typing import Any

from .bridge import BridgePage
from .browser_urls import build_category_suggest_url, build_post_ad_create_url
from .errors import BrowserAutomationError

_FETCH_CATEGORY_SUGGEST_JS = """
((apiUrl) => {
  return fetch(apiUrl, {
    method: "GET",
    credentials: "include",
    headers: { "Accept": "application/json" },
  })
    .then((res) => {
      if (!res.ok) {
        throw new Error("类目建议 API 返回 " + res.status);
      }
      return res.json();
    })
    .then((data) => JSON.stringify(data));
})
"""

_EXTRACT_POST_AD_FIELDS_JS = """
(async () => {
  const form = document.querySelector("#syi-form") || document.querySelector("form");
  if (!form) {
    throw new Error("未找到发布表单");
  }

  const clean = (value) => (value || "").replace(/\\s+/g, " ").trim();
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  const isVisible = (el) => {
    if (!(el instanceof HTMLElement)) return false;
    if (el.hidden) return false;
    const style = window.getComputedStyle(el);
    if (style.display === "none" || style.visibility === "hidden") return false;
    return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
  };

  const getPanel = (el) => {
    const panel =
      el.closest("[data-validation-group]") ||
      el.closest(".panel") ||
      el.closest("fieldset");
    if (!panel) return "";
    const heading = panel.querySelector("h1, h2, h3, h4, legend");
    const panelName =
      panel.getAttribute("data-validation-group") ||
      panel.getAttribute("data-panel-name") ||
      "";
    return clean(heading?.textContent || panelName);
  };

  const getLabel = (el) => {
    const id = el.getAttribute("id");
    const directLabel = id
      ? document.querySelector(`label[for="${CSS.escape(id)}"]`)
      : null;
    if (directLabel) return clean(directLabel.textContent);

    const wrappingLabel = el.closest("label");
    if (wrappingLabel) return clean(wrappingLabel.textContent);

    const formElement =
      el.closest(".form-element") ||
      el.closest(".field") ||
      el.closest("fieldset") ||
      el.parentElement;
    if (!formElement) return clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.name);

    const candidate = formElement.querySelector(
      "label, legend, [data-testid*='label'], [class*='label'], [class*='title']"
    );
    return clean(
      candidate?.textContent ||
        el.getAttribute("aria-label") ||
        el.getAttribute("placeholder") ||
        el.name
    );
  };

  const getGroupLabel = (el) => {
    const container =
      el.closest("fieldset") ||
      el.closest("[role='radiogroup']") ||
      el.closest(".form-element") ||
      el.closest(".panel");
    if (!container) return getLabel(el);
    const candidate = container.querySelector(
      "legend, h1, h2, h3, h4, [data-testid*='label'], [class*='label'], [class*='title']"
    );
    return clean(candidate?.textContent || getPanel(el) || getLabel(el));
  };

  const hasRequiredMarker = (text) => /\\*/.test(text || "");

  const isRequiredField = (el, labelText = "") =>
    !!(
      el.required ||
      el.getAttribute("aria-required") === "true" ||
      el.closest("[data-required='true']") ||
      hasRequiredMarker(labelText)
    );

  const buildOption = (input) => ({
    value: input.value,
    label: getLabel(input) || clean(input.value),
    checked: !!input.checked,
  });

  const seen = new Set();
  const fields = [];
  const dialogOptionMap = {};
  const diagnostics = {
    modal_triggers: [],
    modal_option_counts: {},
  };

  const pushField = (field) => {
    fields.push({
      ...field,
      label: clean(field.label),
      panel: clean(field.panel),
    });
    seen.add(field.name);
  };

  const findFieldContainer = (el) =>
    el.closest(".form-element") || el.closest(".panel") || el.parentElement;

  const resolveTriggerFieldName = (trigger) => {
    const container = findFieldContainer(trigger);
    if (!container) return "";

    const nearbyNamedField = container.querySelector("input[name], select[name], textarea[name]");
    if (nearbyNamedField instanceof HTMLElement) {
      return nearbyNamedField.getAttribute("name") || "";
    }

    const key = (trigger.getAttribute("data-q") || "").replace(/-modal-trigger$/, "");
    if (!key) return "";

    const exact = form.querySelector(`[name="attributes[${CSS.escape(key)}]"]`);
    if (exact instanceof HTMLElement) {
      return exact.getAttribute("name") || "";
    }

    return `attributes[${key}]`;
  };

  const clickRegularMode = async () => {
    const regular = document.querySelector('input#regular[name="attributes[mazuma_listing_options]"]');
    if (!(regular instanceof HTMLInputElement)) {
      return false;
    }
    regular.click();
    regular.checked = true;
    regular.dispatchEvent(new Event("input", { bubbles: true }));
    regular.dispatchEvent(new Event("change", { bubbles: true }));
    regular.dispatchEvent(new Event("blur", { bubbles: true }));
    return true;
  };

  const waitForFullForm = async () => {
    const deadline = Date.now() + 10000;
    while (Date.now() < deadline) {
      const titleInput = form.querySelector('input[name="title"]');
      const descriptionInput = form.querySelector('textarea[name="description"]');
      if (titleInput && descriptionInput) {
        return true;
      }
      await sleep(200);
    }
    return false;
  };

  const captureVisibleDialogOptions = () => {
    const dialog =
      document.querySelector('[role="dialog"]') ||
      document.querySelector('[data-testid="dialog-content"]') ||
      document.querySelector('.dialog-content');
    if (!(dialog instanceof HTMLElement) || !isVisible(dialog)) {
      return;
    }

    const grouped = {};
    Array.from(dialog.querySelectorAll("input, select, textarea")).forEach((el) => {
      const name = el.getAttribute("name");
      if (!name || el.disabled) return;
      const type =
        el.tagName.toLowerCase() === "input"
          ? (el.getAttribute("type") || "text").toLowerCase()
          : el.tagName.toLowerCase();
      if (type === "hidden") return;
      if (!grouped[name]) grouped[name] = [];
      grouped[name].push(el);
    });

    Object.entries(grouped).forEach(([name, nodes]) => {
      const first = nodes[0];
      const type =
        first.tagName.toLowerCase() === "input"
          ? (first.getAttribute("type") || "text").toLowerCase()
          : first.tagName.toLowerCase();

      if (type === "radio") {
        dialogOptionMap[name] = {
          type: "radio",
          options: nodes.map((node) => buildOption(node)),
        };
        return;
      }

      if (type === "select") {
        dialogOptionMap[name] = {
          type: "select",
          options: Array.from(first.querySelectorAll("option")).map((option) => ({
            value: option.value,
            label: clean(option.textContent),
            selected: option.selected,
            disabled: option.disabled,
          })),
        };
      }
    });
  };

  const closeDialog = async () => {
    const overlay = document.querySelector('[data-testid="overlay"]');
    if (overlay instanceof HTMLElement) {
      overlay.click();
      await sleep(150);
      return;
    }

    document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
    await sleep(150);
  };

  const expandFieldTriggers = async () => {
    const triggers = Array.from(form.querySelectorAll('button[data-q$="-modal-trigger"]'));
    for (const trigger of triggers) {
      if (!(trigger instanceof HTMLButtonElement) || !isVisible(trigger)) continue;
      diagnostics.modal_triggers.push({
        trigger: trigger.getAttribute("data-q") || "",
        text: clean(trigger.textContent || ""),
      });
      trigger.click();
      await sleep(250);
      captureVisibleDialogOptions();
      Object.entries(dialogOptionMap).forEach(([name, value]) => {
        diagnostics.modal_option_counts[name] = value.options?.length || 0;
      });
      await closeDialog();
    }
  };

  await clickRegularMode();
  await waitForFullForm();
  await sleep(300);
  await expandFieldTriggers();

  Array.from(form.querySelectorAll('button[data-q$="-modal-trigger"]')).forEach((trigger) => {
    if (!(trigger instanceof HTMLButtonElement) || !isVisible(trigger)) return;

    const name = resolveTriggerFieldName(trigger);
    if (!name || seen.has(name)) return;

    const container = findFieldContainer(trigger);
    const labelNode =
      container?.querySelector('label, [data-q$="-field-title"], [class*="label"], [class*="title"]') ||
      trigger;
    const label = clean(labelNode?.textContent || trigger.textContent || name);
    const backingField = container?.querySelector(`input[name="${CSS.escape(name)}"], select[name="${CSS.escape(name)}"], textarea[name="${CSS.escape(name)}"]`);
    const panel = container ? getPanel(container) : "";
    const options = dialogOptionMap[name]?.options || [];

    pushField({
      name,
      type: options.length ? dialogOptionMap[name]?.type || "modal_select" : "modal_select",
      label,
      panel,
      required: !!(backingField && isRequiredField(backingField, label)) || hasRequiredMarker(label),
      value:
        backingField instanceof HTMLInputElement ||
        backingField instanceof HTMLTextAreaElement ||
        backingField instanceof HTMLSelectElement
          ? backingField.value
          : "",
      placeholder: clean(trigger.textContent || ""),
      trigger: trigger.getAttribute("data-q") || "",
      options,
    });
  });

  Array.from(form.querySelectorAll("input, select, textarea")).forEach((el) => {
    const name = el.getAttribute("name");
    if (!name || seen.has(name) || el.disabled) return;

    const type =
      el.tagName.toLowerCase() === "input"
        ? (el.getAttribute("type") || "text").toLowerCase()
        : el.tagName.toLowerCase();

    if (type === "hidden") return;
    if (type !== "radio" && !isVisible(el)) return;

    if (type === "radio") {
      const radios = Array.from(
        form.querySelectorAll(`input[type="radio"][name="${CSS.escape(name)}"]`)
      );
      if (!radios.length) return;
      pushField({
        name,
        type: "radio",
        label: getGroupLabel(radios[0]),
        panel: getPanel(radios[0]),
        required: radios.some((item) => isRequiredField(item, getGroupLabel(item))),
        options: dialogOptionMap[name]?.options || radios.map(buildOption),
      });
      return;
    }

    if (type === "checkbox") {
      const checkboxes = Array.from(
        form.querySelectorAll(`input[type="checkbox"][name="${CSS.escape(name)}"]`)
      ).filter((item) => isVisible(item));
      if (checkboxes.length > 1) {
        pushField({
          name,
          type: "checkbox_group",
          label: getGroupLabel(checkboxes[0]),
          panel: getPanel(checkboxes[0]),
          required: checkboxes.some((item) => isRequiredField(item, getGroupLabel(item))),
          options: checkboxes.map(buildOption),
        });
        return;
      }
    }

    if (type === "select") {
      pushField({
        name,
        type: "select",
        label: getLabel(el),
        panel: getPanel(el),
        required: isRequiredField(el, getLabel(el)),
        options: (dialogOptionMap[name]?.options || Array.from(el.querySelectorAll("option"))
          .map((option) => ({
            value: option.value,
            label: clean(option.textContent),
            selected: option.selected,
            disabled: option.disabled,
          }))).filter((option) => option.value || option.label),
      });
      return;
    }

    pushField({
      name,
      type,
      label: getLabel(el),
      panel: getPanel(el),
      required: isRequiredField(el, getLabel(el)),
      value: "value" in el ? el.value : "",
      placeholder: el.getAttribute("placeholder") || "",
    });
  });

  const imagePanel = form.querySelector('[data-validation-group="images"][data-required="true"]');
  if (imagePanel) {
    fields.push({
      name: "images-file-input",
      type: "file",
      required: true,
      label: "Photos",
      panel: clean(getPanel(imagePanel)),
      note: "Add at least 1 image",
    });
  }

  const requiredFields = fields.filter((field) => field.required);

  return JSON.stringify({
    total_fields: fields.length,
    required_count: requiredFields.length,
    required_fields: requiredFields,
  //  all_fields: fields,
  //  diagnostics,
  });
})()
"""


def _evaluate_json(page: BridgePage, expression: str) -> dict[str, Any]:
    raw = page.evaluate(expression)
    if not raw:
        raise BrowserAutomationError("浏览器返回了空结果")
    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("浏览器结果格式异常")
    return payload


def _wait_for_expression(
    page: BridgePage,
    expression: str,
    timeout: float,
    interval: float,
    message: str,
) -> Any:
    deadline = time.time() + timeout
    while time.time() < deadline:
        result = page.evaluate(expression)
        if result:
            return result
        time.sleep(interval)
    raise BrowserAutomationError(message)


def _format_category(cat: dict[str, Any], index: int) -> dict[str, Any]:
    return {
        "index": index,
        "id": cat.get("id"),
        "displayName": cat.get("displayName"),
        "tree": cat.get("tree"),
    }


def _find_best_category(
    categories: list[dict[str, Any]],
    category_name: str,
) -> tuple[int, dict[str, Any]] | None:
    target = category_name.strip().lower()
    for i, cat in enumerate(categories):
        if cat.get("displayName", "").strip().lower() == target:
            return i, cat
    for i, cat in enumerate(categories):
        if target in cat.get("displayName", "").strip().lower():
            return i, cat
    for i, cat in enumerate(categories):
        if target in cat.get("tree", "").strip().lower():
            return i, cat
    return None


def _extract_post_ad_fields(page: BridgePage) -> dict[str, Any]:
    return _evaluate_json(page, _EXTRACT_POST_AD_FIELDS_JS)


def _prepare_post_ad_form(page: BridgePage) -> None:
    has_regular = page.evaluate(
        """
(() => {
  const regular = document.querySelector('input#regular[name="attributes[mazuma_listing_options]"]');
  if (!(regular instanceof HTMLInputElement)) {
    return false;
  }
  regular.click();
  regular.checked = true;
  regular.dispatchEvent(new Event("input", { bubbles: true }));
  regular.dispatchEvent(new Event("change", { bubbles: true }));
  regular.dispatchEvent(new Event("blur", { bubbles: true }));
  return true;
})()
"""
    )
    if has_regular:
        _wait_for_expression(
            page,
            """(() => Boolean(
              document.querySelector('#syi-form input[name="title"]') &&
              document.querySelector('#syi-form textarea[name="description"]')
            ))()""",
            timeout=10.0,
            interval=0.2,
            message="点击 Sell on Gumtree 后，完整发布表单未在预期时间内出现",
        )
    page.wait_dom_stable(timeout=10000)


def run_browser_post_ad_category(
    page: BridgePage,
    keyword: str,
    category_name: str | None = None,
    category_index: int | None = None,
) -> dict[str, Any]:
    api_url = build_category_suggest_url(keyword)
    result = _evaluate_json(
        page, f"{_FETCH_CATEGORY_SUGGEST_JS}({json.dumps(api_url)})"
    )

    categories = result.get("categories", [])
    display_categories = [
        _format_category(cat, i) for i, cat in enumerate(categories)
    ]

    if not categories:
        return {
            "ok": False,
            "mode": "browser",
            "keyword": keyword,
            "error": f"关键词 '{keyword}' 未返回任何建议类目",
            "categories": [],
        }

    if category_index is None and category_name is None:
        return {
            "ok": True,
            "mode": "browser",
            "step": "suggest",
            "keyword": keyword,
            "message": "已获取建议类目，请选择"
            "（传入 --category-name 或 --category-index）",
            "categories": display_categories,
        }

    selected_cat: dict[str, Any] | None = None
    selected_idx: int | None = None

    if category_index is not None:
        if category_index < 0 or category_index >= len(categories):
            return {
                "ok": False,
                "mode": "browser",
                "keyword": keyword,
                "error": (
                    f"类目索引 {category_index} 超出范围，"
                    f"共 {len(categories)} 个建议类目"
                ),
                "categories": display_categories,
            }
        selected_idx = category_index
        selected_cat = categories[category_index]
    elif category_name is not None:
        match = _find_best_category(categories, category_name)
        if match is None:
            return {
                "ok": False,
                "mode": "browser",
                "keyword": keyword,
                "error": f"未在建议类目中找到匹配 '{category_name}' 的项",
                "categories": display_categories,
            }
        selected_idx, selected_cat = match

    category_id = selected_cat["id"]
    create_url = build_post_ad_create_url(category_id)
    page.navigate(create_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)
    _prepare_post_ad_form(page)

    final_url_raw = page.evaluate("window.location.href")
    final_url = (
        final_url_raw.strip('"') if isinstance(final_url_raw, str) else ""
    )
    post_ad_fields = _extract_post_ad_fields(page)

    return {
        "ok": True,
        "mode": "browser",
        "step": "category_selected",
        "keyword": keyword,
        "categories": display_categories,
        "selected_category": _format_category(selected_cat, selected_idx),
        "redirected_url": final_url,
        "post_ad_fields": post_ad_fields,
    }


path: scripts/gumtree/browser_home.py
content: from __future__ import annotations

import json
from typing import Any

from .bridge import BridgePage
from .browser_urls import build_home_url
from .errors import BrowserAutomationError

_EXTRACT_HOME_RECOMMENDATIONS_JS = """
(() => {
  const fail = (message) => {
    throw new Error(message);
  };
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const expectObject = (value, message) => {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectArray = (value, message) => {
    if (!Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectString = (value, message) => {
    const text = normalizeText(value);
    if (!text) {
      fail(message);
    }
    return text;
  };
  const expectBoolean = (value, message) => {
    if (typeof value !== "boolean") {
      fail(message);
    }
    return value;
  };
  const expectFiniteNumber = (value, message) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      fail(message);
    }
    return value;
  };
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const optionalFiniteNumber = (value) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      return null;
    }
    return value;
  };
  const toAbsoluteUrl = (value) => {
    const text = normalizeText(value);
    if (!text) {
      return null;
    }
    try {
      return new URL(text, window.location.origin).toString();
    } catch {
      return null;
    }
  };

  const rawClientData = window.clientData;
  if (rawClientData == null) {
    fail("window.clientData 缺失");
  }
  let clientData;
  if (typeof rawClientData === "string") {
    try {
      clientData = JSON.parse(decodeURIComponent(rawClientData));
    } catch (error) {
      fail(`window.clientData 解析失败: ${error.message}`);
    }
  } else if (typeof rawClientData === "object" && !Array.isArray(rawClientData)) {
    clientData = rawClientData;
  } else {
    fail("window.clientData 格式异常");
  }

  const root = expectObject(clientData, "clientData 格式异常");
  const page = expectObject(root.page, "clientData.page 缺失");
  const socialData = expectObject(root.socialData, "clientData.socialData 缺失");
  const userFeed = expectObject(root.userFeed, "clientData.userFeed 缺失");
  const recommendedAds = expectArray(userFeed.recommendedAds, "clientData.userFeed.recommendedAds 缺失");

  const items = recommendedAds.flatMap((item, index) => {
    if (!item || typeof item !== "object" || Array.isArray(item)) {
      return [];
    }
    const row = item;
    const price = row.price && typeof row.price === "object" && !Array.isArray(row.price) ? row.price : null;
    const priceAmount = optionalFiniteNumber(price?.amount);
    const listingId = optionalFiniteNumber(row.id);
    const title = optionalString(row.title);
    const url = toAbsoluteUrl(row.path);
    if (!title && !url && listingId == null) {
      return [];
    }
    return [{
      listing_id: listingId == null ? null : String(listingId),
      title,
      description: optionalString(row.shortDescription),
      price: priceAmount == null ? null : priceAmount / 100,
      price_pennies: priceAmount,
      price_currency: optionalString(price?.currency),
      location: optionalString(row.location),
      url,
      image_url: toAbsoluteUrl(row.imageUrl || row.pictureUrl),
      age: optionalString(row.timeSincePosted),
      number_of_images: optionalFiniteNumber(row.numberOfImages),
      category: optionalString(row.category),
      top_level_category: optionalString(row.l1Category),
      spotlight: optionalBoolean(row.spotlight),
      delivery_available: optionalBoolean(row.supportShipping),
    }];
  });

  return JSON.stringify({
    source: "clientData.userFeed.recommendedAds",
    meta: {
      page_type: optionalString(page.type),
      page_title: optionalString(page.title),
      location_name: optionalString(socialData.location),
      total_recommendations: recommendedAds.length,
    },
    items,
  });
})()
"""


def run_browser_home_recommendations(
    page: BridgePage,
    limit: int = 10,
) -> dict[str, Any]:
    home_url = build_home_url()
    page.navigate(home_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    raw = page.evaluate(_EXTRACT_HOME_RECOMMENDATIONS_JS)
    if not raw:
        raise BrowserAutomationError("未能从 Gumtree 首页提取推荐结果")

    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("首页推荐结果格式异常")

    items = payload.get("items")
    if not isinstance(items, list):
        raise BrowserAutomationError("首页推荐结果格式异常")

    meta = payload.get("meta")
    if not isinstance(meta, dict):
        raise BrowserAutomationError("首页推荐元信息格式异常")

    display_item_fields = (
        "title",
        "description",
        "price",
        "price_currency",
        "location",
        "url",
        "image_url",
        "age",
        "number_of_images",
        "category",
        "top_level_category",
        "spotlight",
        "delivery_available",
    )

    cleaned = []
    for item in items:
        if not isinstance(item, dict):
            continue
        cleaned.append({field: item.get(field) for field in display_item_fields})

    limited = cleaned[:limit]
    return {
        "ok": True,
        "mode": "browser",
        "source": payload.get("source"),
        "page_type": meta.get("page_type"),
        "page_title": meta.get("page_title"),
        "location_name": meta.get("location_name"),
        "home_url": home_url,
        "items": limited,
        "total": len(limited),
        "total_recommendations": meta.get("total_recommendations"),
    }


path: scripts/gumtree/bridge.py
content: from __future__ import annotations

import json
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

import websockets.sync.client as ws_client

from .errors import BridgeError

logger = logging.getLogger("gumtree-bridge-client")

BRIDGE_PORT = 9335
BRIDGE_URL = f"ws://localhost:{BRIDGE_PORT}"


class BridgePage:
    def __init__(self, bridge_url: str = BRIDGE_URL, timeout: float = 90.0) -> None:
        self._bridge_url = bridge_url
        self._timeout = timeout

    def _call(self, method: str, params: dict[str, Any] | None = None) -> Any:
        msg: dict[str, Any] = {"role": "cli", "method": method}
        if params:
            msg["params"] = params
        try:
            with ws_client.connect(self._bridge_url, close_timeout=5) as conn:
                conn.send(json.dumps(msg, ensure_ascii=False))
                raw = conn.recv(timeout=self._timeout)
        except OSError as exc:
            raise BridgeError(f"无法连接到 bridge server（{self._bridge_url}）: {exc}") from exc

        result = json.loads(raw)
        if "error" in result and result["error"]:
            raise BridgeError(result["error"])
        return result.get("result")

    def navigate(self, url: str) -> None:
        self._call("navigate", {"url": url})

    def wait_for_load(self, timeout: int = 60000) -> None:
        self._call("wait_for_load", {"timeout": timeout})

    def wait_dom_stable(self, timeout: int = 10000, interval: int = 500) -> None:
        self._call("wait_dom_stable", {"timeout": timeout, "interval": interval})

    def evaluate(self, expression: str) -> Any:
        return self._call("evaluate", {"expression": expression})

    def is_server_running(self) -> bool:
        try:
            with ws_client.connect(self._bridge_url, open_timeout=3, close_timeout=5) as conn:
                conn.send(json.dumps({"role": "cli", "method": "ping_server"}, ensure_ascii=False))
                raw = conn.recv(timeout=5)
        except Exception:
            return False

        result = json.loads(raw)
        return "result" in result

    def is_extension_connected(self) -> bool:
        try:
            with ws_client.connect(self._bridge_url, open_timeout=3, close_timeout=5) as conn:
                conn.send(json.dumps({"role": "cli", "method": "ping_server"}, ensure_ascii=False))
                raw = conn.recv(timeout=5)
        except Exception:
            return False

        result = json.loads(raw)
        return bool(result.get("result", {}).get("extension_connected"))


def _open_chrome() -> None:
    candidates = [
        r"C:\Program Files\Google\Chrome\Application\chrome.exe",
        r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
        os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
    ]
    for path in candidates:
        if os.path.exists(path):
            subprocess.Popen([path])
            return

    for cmd in [["open", "-a", "Google Chrome"], ["google-chrome"], ["chromium-browser"]]:
        try:
            subprocess.Popen(cmd)
            return
        except FileNotFoundError:
            continue
    logger.warning("找不到 Chrome，请手动打开浏览器")


def ensure_bridge_ready(port: int = BRIDGE_PORT) -> None:
    client = BridgePage(bridge_url=f"ws://localhost:{port}")

    if not client.is_server_running():
        logger.info("Bridge server 未运行，正在启动...")
        server_script = Path(__file__).resolve().parent.parent / "bridge_server.py"
        kwargs: dict[str, Any] = {
            "stdout": subprocess.DEVNULL,
            "stderr": subprocess.DEVNULL,
        }
        if sys.platform == "win32":
            kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
        subprocess.Popen(
            [sys.executable, str(server_script), "--port", str(port)],
            **kwargs,
        )

        for _ in range(10):
            time.sleep(1)
            if client.is_server_running():
                logger.info("Bridge server 已启动")
                break
        else:
            raise BridgeError("Bridge server 启动超时，请手动运行 scripts/bridge_server.py")

    if client.is_extension_connected():
        return

    logger.info("浏览器扩展未连接，正在打开 Chrome...")
    _open_chrome()

    for _ in range(20):
        time.sleep(1)
        if client.is_extension_connected():
            logger.info("浏览器扩展已连接")
            return

    raise BridgeError("等待 Gumtree Bridge 扩展连接超时，请确认扩展已安装并启用")


path: scripts/gumtree/__init__.py
content: """Gumtree MVP browser modules."""


path: scripts/gumtree/browser_auth.py
content: from __future__ import annotations

import json
import time
from typing import Any

from .bridge import BridgePage
from .browser_urls import build_home_url
from .errors import BrowserAutomationError

_EXTRACT_LOGIN_STATUS_JS = """
(() => {
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const toBoolOrNull = (value) => typeof value === "boolean" ? value : null;
  const toStringOrNull = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const toNumberOrNull = (value) => {
    if (typeof value === "number" && Number.isFinite(value)) {
      return value;
    }
    return null;
  };
  const toObjectOrNull = (value) => {
    return value && typeof value === "object" && !Array.isArray(value) ? value : null;
  };
  const containsText = (needle) => {
    const matcher = needle.toLowerCase();
    const nodes = Array.from(document.querySelectorAll("a, button, span, div"));
    return nodes.some((node) => normalizeText(node.textContent).toLowerCase() === matcher);
  };
  const queryHref = (selector) => {
    const node = document.querySelector(selector);
    if (!node) {
      return null;
    }
    const href = toStringOrNull(node.getAttribute("href"));
    if (!href) {
      return null;
    }
    try {
      return new URL(href, window.location.origin).toString();
    } catch {
      return href;
    }
  };

  const analyticsConfig = window.__GUMTREE_ANALYTICS_CONFIG__ || null;
  const dataLayerEntries = Array.isArray(window.dataLayer) ? window.dataLayer : [];
  const initialDataLayer = dataLayerEntries.find(
    (entry) => entry && typeof entry === "object" && !Array.isArray(entry) && entry.name === "HomePageViewEvent"
  ) || null;
  const gumtreeDataLayer = Array.isArray(window.gumtreeDataLayer) ? window.gumtreeDataLayer : [];
  let clientData = null;
  if (typeof window.clientData === "string" && window.clientData) {
    try {
      clientData = JSON.parse(decodeURIComponent(window.clientData));
    } catch {
      clientData = null;
    }
  } else {
    clientData = toObjectOrNull(window.clientData);
  }
  const clientUserData = toObjectOrNull(clientData?.userData);
  const pageDeclaration = gumtreeDataLayer.find(
    (entry) => entry && typeof entry === "object" && !Array.isArray(entry) && entry.event === "pageDeclaration"
  ) || null;

  const signals = {
    analytics_islogin: toBoolOrNull(analyticsConfig?.islogin),
    analytics_user_id: toStringOrNull(analyticsConfig?.userId),
    initial_datalayer_logged_in: toBoolOrNull(initialDataLayer?.u?.li),
    gumtree_logged_in_status: toStringOrNull(pageDeclaration?.user?.loggedInStatus),
    legacy_logged_in: toBoolOrNull(pageDeclaration?.legacy?.loggedIn),
    gumtree_user_id: toNumberOrNull(pageDeclaration?.user?.userId),
    dom_manage_my_ads: containsText("Manage my Ads"),
    dom_my_orders: containsText("My Orders"),
    dom_favourites: containsText("Favourites"),
    dom_my_alerts: containsText("My Alerts"),
    dom_my_details: containsText("My Details"),
    dom_sign_up: containsText("Sign up"),
    dom_login: containsText("Login"),
  };
  const account = {
    id: toNumberOrNull(clientUserData?.id) ?? toNumberOrNull(pageDeclaration?.user?.userId),
    email: toStringOrNull(clientUserData?.email),
    first_name: toStringOrNull(clientUserData?.firstName),
    last_name: toStringOrNull(clientUserData?.lastName),
    full_name: toStringOrNull(
      [clientUserData?.firstName, clientUserData?.lastName].filter(Boolean).join(" ")
    ),
  };
  const menuLinks = {
    manage_my_ads: queryHref('[data-q="user-menu-link-manage-my-ads"]'),
    my_orders: queryHref('[data-q="user-menu-link-my-orders"]'),
    favourites: queryHref('[data-q="user-menu-link-favourites"]'),
    my_alerts: queryHref('[data-q="user-menu-link-my-alerts"]'),
    my_details: queryHref('[data-q="user-menu-link-my-details"]'),
  };

  let positiveScore = 0;
  let negativeScore = 0;
  const matchedSignals = [];

  const push = (name, outcome, weight, detail) => {
    matchedSignals.push({ name, outcome, weight, detail });
    if (outcome === "positive") {
      positiveScore += weight;
    } else if (outcome === "negative") {
      negativeScore += weight;
    }
  };

  if (signals.analytics_islogin === true) {
    push("analytics_islogin", "positive", 5, "window.__GUMTREE_ANALYTICS_CONFIG__.islogin === true");
  } else if (signals.analytics_islogin === false) {
    push("analytics_islogin", "negative", 5, "window.__GUMTREE_ANALYTICS_CONFIG__.islogin === false");
  }

  if (signals.analytics_user_id) {
    push("analytics_user_id", "positive", 3, "window.__GUMTREE_ANALYTICS_CONFIG__.userId 非空");
  } else if (signals.analytics_user_id === null && analyticsConfig && analyticsConfig.userId === "") {
    push("analytics_user_id", "negative", 3, "window.__GUMTREE_ANALYTICS_CONFIG__.userId 为空");
  }

  if (signals.initial_datalayer_logged_in === true) {
    push("initial_datalayer_logged_in", "positive", 4, "initialDataLayer.u.li === true");
  } else if (signals.initial_datalayer_logged_in === false) {
    push("initial_datalayer_logged_in", "negative", 4, "initialDataLayer.u.li === false");
  }

  if (signals.gumtree_logged_in_status === "logged in") {
    push("gumtree_logged_in_status", "positive", 4, "gumtreeDataLayer.user.loggedInStatus === 'logged in'");
  } else if (signals.gumtree_logged_in_status === "logged out") {
    push("gumtree_logged_in_status", "negative", 4, "gumtreeDataLayer.user.loggedInStatus === 'logged out'");
  }

  if (signals.legacy_logged_in === true) {
    push("legacy_logged_in", "positive", 3, "gumtreeDataLayer.legacy.loggedIn === true");
  } else if (signals.legacy_logged_in === false) {
    push("legacy_logged_in", "negative", 3, "gumtreeDataLayer.legacy.loggedIn === false");
  }

  if (signals.gumtree_user_id != null) {
    push("gumtree_user_id", "positive", 2, "gumtreeDataLayer.user.userId 非空");
  }

  const positiveDomSignals = [
    ["dom_manage_my_ads", signals.dom_manage_my_ads, "DOM 包含 Manage my Ads"],
    ["dom_my_orders", signals.dom_my_orders, "DOM 包含 My Orders"],
    ["dom_favourites", signals.dom_favourites, "DOM 包含 Favourites"],
    ["dom_my_alerts", signals.dom_my_alerts, "DOM 包含 My Alerts"],
    ["dom_my_details", signals.dom_my_details, "DOM 包含 My Details"],
  ];
  for (const [name, present, detail] of positiveDomSignals) {
    if (present) {
      push(name, "positive", 1, detail);
    }
  }

  const negativeDomSignals = [
    ["dom_sign_up", signals.dom_sign_up, "DOM 包含 Sign up"],
    ["dom_login", signals.dom_login, "DOM 包含 Login"],
  ];
  for (const [name, present, detail] of negativeDomSignals) {
    if (present) {
      push(name, "negative", 1, detail);
    }
  }

  let loggedIn = null;
  if (signals.analytics_islogin !== null) {
    loggedIn = signals.analytics_islogin;
  } else if (signals.initial_datalayer_logged_in !== null) {
    loggedIn = signals.initial_datalayer_logged_in;
  } else if (signals.gumtree_logged_in_status === "logged in") {
    loggedIn = true;
  } else if (signals.gumtree_logged_in_status === "logged out") {
    loggedIn = false;
  } else if (positiveScore !== negativeScore) {
    loggedIn = positiveScore > negativeScore;
  }

  const scoreDelta = Math.abs(positiveScore - negativeScore);
  let confidence = "low";
  if (signals.analytics_islogin !== null && signals.initial_datalayer_logged_in !== null) {
    confidence = "high";
  } else if (scoreDelta >= 4) {
    confidence = "medium";
  }
  if (signals.analytics_islogin === signals.initial_datalayer_logged_in && signals.analytics_islogin !== null) {
    confidence = "high";
  }

  return JSON.stringify({
    source: "gumtree-login-signals",
    logged_in: loggedIn,
    confidence,
    positive_score: positiveScore,
    negative_score: negativeScore,
    signals,
    matched_signals: matchedSignals,
    page_title: toStringOrNull(document.title),
    current_url: window.location.href,
    account,
    menu_links: menuLinks,
  });
})()
"""

_HAS_EMAIL_LOGIN_FORM_JS = """
(() => {
  return Boolean(
    document.querySelector('input[data-testid="input-username"]') &&
    document.querySelector('input[data-testid="input-password"]')
  );
})()
"""

_IS_LOGIN_SUBMIT_ENABLED_JS = """
(() => {
  const submitButton = document.querySelector('form[action="/bff-api/login/via-form"] button[type="submit"]');
  return Boolean(submitButton && !submitButton.disabled);
})()
"""

_OPEN_LOGIN_UI_JS = """
(() => {
  if (document.querySelector('input[data-testid="input-username"]')) {
    return JSON.stringify({ ok: true, state: "form_visible" });
  }
  const btn = document.querySelector('[data-q="hm-login"]');
  if (!btn) {
    throw new Error("Login button not found");
  }
  btn.click();
  return JSON.stringify({ ok: true, state: "login_opened" });
})()
"""

_CLICK_EMAIL_LOGIN_JS = """
(() => {
  if (document.querySelector('input[data-testid="input-username"]')) {
    return JSON.stringify({ ok: true, state: "form_visible" });
  }
  const btn = document.querySelector('[data-q="email-login"]');
  if (!btn) {
    throw new Error("Continue with email button not found");
  }
  btn.click();
  return JSON.stringify({ ok: true, state: "email_login_opened" });
})()
"""

_OPEN_USER_MENU_JS = """
(() => {
  const menuButton = document.querySelector('[data-q="user-menu-button"]');
  if (!menuButton) {
    throw new Error("User menu button not found");
  }
  const logoutForm = document.querySelector('form[data-testid="logout-form"]');
  if (logoutForm) {
    return JSON.stringify({ ok: true, state: "menu_visible" });
  }
  menuButton.click();
  return JSON.stringify({ ok: true, state: "menu_opened" });
})()
"""

_HAS_LOGOUT_FORM_JS = """
(() => {
  return Boolean(document.querySelector('form[data-testid="logout-form"] button[type="submit"]'));
})()
"""

_EXTRACT_LOGIN_ERROR_JS = """
(() => {
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const selectors = [
    '[role="alert"]',
    '[aria-live="assertive"]',
    '[aria-live="polite"]',
    '.form-element--error',
    '.error-message',
    '.notification-error',
    '[data-testid*="error"]',
  ];
  for (const selector of selectors) {
    const nodes = Array.from(document.querySelectorAll(selector));
    for (const node of nodes) {
      const text = normalizeText(node.textContent);
      if (text) {
        return text;
      }
    }
  }
  return null;
})()
"""


def _build_fill_login_form_js(username: str, password: str) -> str:
    return f"""
(() => {{
  const usernameValue = {json.dumps(username, ensure_ascii=False)};
  const passwordValue = {json.dumps(password, ensure_ascii=False)};
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype,
    "value"
  )?.set;
  const findInput = (selectors) => {{
    for (const selector of selectors) {{
      const input = document.querySelector(selector);
      if (input) {{
        return input;
      }}
    }}
    return null;
  }};
  const setInputValue = (selectors, value) => {{
    const input = findInput(selectors);
    if (!input) {{
      throw new Error(`Input not found: ${{selectors.join(", ")}}`);
    }}
    if (!nativeInputValueSetter) {{
      throw new Error("Native input value setter not available");
    }}
    input.focus();
    nativeInputValueSetter.call(input, "");
    input.dispatchEvent(new InputEvent("input", {{ bubbles: true, data: "", inputType: "deleteContentBackward" }}));
    nativeInputValueSetter.call(input, value);
    input.dispatchEvent(new InputEvent("input", {{ bubbles: true, data: value, inputType: "insertText" }}));
    input.dispatchEvent(new Event("change", {{ bubbles: true }}));
    input.dispatchEvent(new Event("blur", {{ bubbles: true }}));
    input.setAttribute("value", value);
    if (input.value !== value) {{
      nativeInputValueSetter.call(input, value);
    }}
    return input;
  }};

  const usernameInput = setInputValue(
    ['input[data-testid="input-username"]', '#username', 'input[name="username"]'],
    usernameValue
  );
  const passwordInput = setInputValue(
    ['input[data-testid="input-password"]', '#password', 'input[name="form.password"]', 'input.input-password'],
    passwordValue
  );

  const submitButton = document.querySelector('form[action="/bff-api/login/via-form"] button[type="submit"]');
  return JSON.stringify({{
    ok: true,
    username_filled: usernameInput.value === usernameValue,
    password_filled: passwordInput.value === passwordValue,
    username_length: usernameInput.value.length,
    password_length: passwordInput.value.length,
    submit_disabled: Boolean(submitButton?.disabled),
  }});
}})()
"""


_SUBMIT_EMAIL_LOGIN_JS = """
(() => {
  const form = document.querySelector('form[action="/bff-api/login/via-form"]');
  if (!form) {
    throw new Error("Email login form not found");
  }
  const submitButton = form.querySelector('button[type="submit"]');
  if (!submitButton) {
    throw new Error("Login submit button not found");
  }
  if (submitButton.disabled) {
    throw new Error("Login submit button is disabled");
  }
  submitButton.click();
  return JSON.stringify({ ok: true });
})()
"""

_SUBMIT_LOGOUT_JS = """
(() => {
  const form = document.querySelector('form[data-testid="logout-form"]');
  if (!form) {
    throw new Error("Logout form not found");
  }
  const submitButton = form.querySelector('button[type="submit"]');
  if (!submitButton) {
    throw new Error("Logout submit button not found");
  }
  submitButton.click();
  return JSON.stringify({ ok: true });
})()
"""


def _evaluate_json(page: BridgePage, expression: str) -> dict[str, Any]:
    raw = page.evaluate(expression)
    if not raw:
        raise BrowserAutomationError("浏览器返回了空结果")
    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("浏览器结果格式异常")
    return payload


def _wait_for_expression(page: BridgePage, expression: str, timeout: float, interval: float, message: str) -> Any:
    deadline = time.time() + timeout
    while time.time() < deadline:
        result = page.evaluate(expression)
        if result:
            return result
        time.sleep(interval)
    raise BrowserAutomationError(message)


def _extract_login_status_from_current_page(page: BridgePage) -> dict[str, Any]:
    raw = page.evaluate(_EXTRACT_LOGIN_STATUS_JS)
    if not raw:
        raise BrowserAutomationError("未能从 Gumtree 页面提取登录状态")

    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("登录状态结果格式异常")

    logged_in = payload.get("logged_in")
    if logged_in is not None and not isinstance(logged_in, bool):
        raise BrowserAutomationError("登录状态字段格式异常")

    result = {"ok": True, "logged_in": logged_in}

    if logged_in:
        account = payload.get("account")
        if not isinstance(account, dict):
            raise BrowserAutomationError("登录用户信息格式异常")
        menu_links = payload.get("menu_links")
        if not isinstance(menu_links, dict):
            raise BrowserAutomationError("登录菜单信息格式异常")
        result["account"] = account
        result["menu_links"] = menu_links

    return result


def run_browser_check_login(page: BridgePage) -> dict[str, Any]:
    home_url = build_home_url()
    page.navigate(home_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)
    return _extract_login_status_from_current_page(page)


def run_browser_login(page: BridgePage, username: str, password: str) -> dict[str, Any]:
    home_url = build_home_url()
    page.navigate(home_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    current_status = _extract_login_status_from_current_page(page)
    if current_status.get("logged_in") is True:
        current_status["already_logged_in"] = True
        return current_status

    page.evaluate(_OPEN_LOGIN_UI_JS)
    time.sleep(1)
    page.evaluate(_CLICK_EMAIL_LOGIN_JS)
    _wait_for_expression(
        page,
        _HAS_EMAIL_LOGIN_FORM_JS,
        timeout=10,
        interval=0.5,
        message="等待 Gumtree 邮箱登录表单出现超时",
    )

    fill_result = _evaluate_json(page, _build_fill_login_form_js(username=username, [REDACTED_SECRET]))
    if fill_result.get("username_filled") is not True:
        raise BrowserAutomationError("登录表单未成功填入邮箱")
    if fill_result.get("password_filled") is not True:
        raise BrowserAutomationError("登录表单未成功填入密码")
    if fill_result.get("submit_disabled") is True:
        _wait_for_expression(
            page,
            _IS_LOGIN_SUBMIT_ENABLED_JS,
            timeout=5,
            interval=0.5,
            message="登录表单未就绪，提交按钮仍然不可用",
        )

    page.evaluate(_SUBMIT_EMAIL_LOGIN_JS)

    deadline = time.time() + 20
    while time.time() < deadline:
        time.sleep(1)
        page.wait_dom_stable(timeout=5000)

        error_text = page.evaluate(_EXTRACT_LOGIN_ERROR_JS)
        if isinstance(error_text, str) and error_text.strip():
            raise BrowserAutomationError(f"Gumtree 登录失败: {error_text.strip()}")

        current_status = _extract_login_status_from_current_page(page)
        if current_status.get("logged_in") is True:
            current_status["just_logged_in"] = True
            return current_status

    raise BrowserAutomationError("等待 Gumtree 登录完成超时，请检查账号密码或页面验证码/风控提示")


def run_browser_logout(page: BridgePage) -> dict[str, Any]:
    home_url = build_home_url()
    page.navigate(home_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    current_status = _extract_login_status_from_current_page(page)
    if current_status.get("logged_in") is not True:
        return {"ok": True, "logged_in": False, "already_logged_out": True}

    page.evaluate(_OPEN_USER_MENU_JS)
    _wait_for_expression(
        page,
        _HAS_LOGOUT_FORM_JS,
        timeout=10,
        interval=0.5,
        message="等待 Gumtree 用户菜单中的退出表单出现超时",
    )
    page.evaluate(_SUBMIT_LOGOUT_JS)

    deadline = time.time() + 20
    while time.time() < deadline:
        time.sleep(1)
        page.wait_dom_stable(timeout=5000)
        current_status = _extract_login_status_from_current_page(page)
        if current_status.get("logged_in") is False:
            return {"ok": True, "logged_in": False, "just_logged_out": True}

    raise BrowserAutomationError("等待 Gumtree 退出登录完成超时")


path: scripts/gumtree/selectors.py
content: SEARCH_RESULTS_READY = "main"


path: scripts/gumtree/browser_detail_favourite.py
content: from __future__ import annotations

import json
import time
from typing import Any

from .bridge import BridgePage
from .browser_auth import _extract_login_status_from_current_page
from .errors import BrowserAutomationError

_EXTRACT_DETAIL_FAVOURITE_STATE_JS = """
(() => {
  const fail = (message) => {
    throw new Error(message);
  };
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const optionalFiniteNumber = (value) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      return null;
    }
    return value;
  };
  const expectObject = (value, message) => {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const decodeClientData = () => {
    const rawClientData = window.clientData;
    if (rawClientData == null) {
      fail("window.clientData 缺失");
    }
    if (typeof rawClientData === "string") {
      try {
        return JSON.parse(decodeURIComponent(rawClientData));
      } catch (error) {
        fail(`window.clientData 解析失败: ${error.message}`);
      }
    }
    if (typeof rawClientData === "object" && !Array.isArray(rawClientData)) {
      return rawClientData;
    }
    fail("window.clientData 格式异常");
  };
  const extractListingId = (clientData) => {
    const fromListingDetails = optionalFiniteNumber(clientData?.listingDetails?.id);
    if (fromListingDetails != null) {
      return String(fromListingDetails);
    }
    const path = optionalString(clientData?.request?.path) || window.location.pathname;
    const match = path.match(/\\/(\\d+)(?:[/?#]|$)/);
    return match ? match[1] : null;
  };

  const clientData = expectObject(decodeClientData(), "clientData 格式异常");
  const sellerActions = expectObject(clientData.sellerActions, "clientData.sellerActions 缺失");
  const savedAds = sellerActions.savedAds && typeof sellerActions.savedAds === "object" && !Array.isArray(sellerActions.savedAds)
    ? sellerActions.savedAds
    : {};
  const listingId = extractListingId(clientData);
  const favouriteButton = document.querySelector('button.favourite, button[data-q="empty-heart"], button[data-q="full-heart"], button[data-q="filled-heart"]');
  const favouriteIcon = favouriteButton?.querySelector(".icon");
  const buttonClass = optionalString(favouriteButton?.getAttribute("class"));
  const buttonDataQ = optionalString(favouriteButton?.getAttribute("data-q"));
  const iconClass = optionalString(favouriteIcon?.getAttribute("class"));
  const savedByDataLayer = listingId ? savedAds[listingId] === true : null;
  const savedByButtonClass = buttonClass ? buttonClass.includes("is-saved") : false;
  const savedByDataQ = buttonDataQ === "full-heart" || buttonDataQ === "filled-heart";
  const savedByIconClass = iconClass ? iconClass.includes("icon--color-pink") : false;
  const isSaved = savedByDataLayer === true || savedByButtonClass || savedByDataQ || savedByIconClass;

  return JSON.stringify({
    listing_id: listingId,
    title: optionalString(clientData?.listingDetails?.title),
    current_url: window.location.href,
    button_found: Boolean(favouriteButton),
    button_disabled: Boolean(favouriteButton?.disabled),
    button_class: buttonClass,
    button_data_q: buttonDataQ,
    button_text: optionalString(favouriteButton?.textContent),
    icon_class: iconClass,
    logged_in: optionalBoolean(clientData?.userData?.userLoggedIn),
    saved_by_data_layer: savedByDataLayer,
    saved_by_button_class: savedByButtonClass,
    saved_by_data_q: savedByDataQ,
    saved_by_icon_class: savedByIconClass,
    is_saved: isSaved,
  });
})()
"""

_CLICK_DETAIL_FAVOURITE_JS = """
(() => {
  const button = document.querySelector('button.favourite, button[data-q="empty-heart"], button[data-q="full-heart"], button[data-q="filled-heart"]');
  if (!button) {
    throw new Error("Favourite button not found");
  }
  if (button.disabled) {
    throw new Error("Favourite button is disabled");
  }
  button.click();
  return JSON.stringify({
    ok: true,
    button_text: String(button.textContent ?? "").replace(/\\s+/g, " ").trim(),
    button_data_q: button.getAttribute("data-q"),
  });
})()
"""


def _evaluate_json(page: BridgePage, expression: str) -> dict[str, Any]:
    raw = page.evaluate(expression)
    if not raw:
        raise BrowserAutomationError("浏览器返回了空结果")
    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("浏览器结果格式异常")
    return payload


def run_browser_detail_favourite(
    page: BridgePage,
    detail_url: str,
) -> dict[str, Any]:
    page.navigate(detail_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    login_status = _extract_login_status_from_current_page(page)
    if login_status.get("logged_in") is not True:
        raise BrowserAutomationError("当前未登录 Gumtree，无法收藏详情页")

    before = _evaluate_json(page, _EXTRACT_DETAIL_FAVOURITE_STATE_JS)
    listing_id = before.get("listing_id")
    if not isinstance(listing_id, str) or not listing_id:
        raise BrowserAutomationError("未能识别当前详情页 listing_id")
    if before.get("button_found") is not True:
        raise BrowserAutomationError("详情页未找到 Favourite 按钮")
    if before.get("button_disabled") is True:
        raise BrowserAutomationError("详情页 Favourite 按钮不可点击")
    if before.get("is_saved") is True:
        return {
            "ok": True,
            "mode": "browser",
            "detail_url": detail_url,
            "listing_id": listing_id,
            "title": before.get("title"),
            "already_favourited": True,
            "just_favourited": False,
            "state": before,
        }

    _evaluate_json(page, _CLICK_DETAIL_FAVOURITE_JS)

    deadline = time.time() + 15
    latest_state = before
    while time.time() < deadline:
        time.sleep(1)
        page.wait_dom_stable(timeout=5000)
        latest_state = _evaluate_json(page, _EXTRACT_DETAIL_FAVOURITE_STATE_JS)
        if latest_state.get("is_saved") is True:
            return {
                "ok": True,
                "mode": "browser",
                "detail_url": detail_url,
                "listing_id": listing_id,
                "title": latest_state.get("title"),
                "already_favourited": False,
                "just_favourited": True,
                "state": latest_state,
            }

    raise BrowserAutomationError(
        f"点击 Favourite 后未确认收藏成功，最后状态: {json.dumps(latest_state, ensure_ascii=False)}"
    )


path: scripts/gumtree/browser_favourites.py
content: from __future__ import annotations

import json
from typing import Any

from .bridge import BridgePage
from .browser_urls import build_favourites_url
from .errors import BrowserAutomationError

_EXTRACT_FAVOURITES_JS = """
(() => {
  const fail = (message) => {
    throw new Error(message);
  };
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const expectObject = (value, message) => {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectArray = (value, message) => {
    if (!Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const optionalFiniteNumber = (value) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      return null;
    }
    return value;
  };
  const toAbsoluteUrl = (value) => {
    const text = normalizeText(value);
    if (!text) {
      return null;
    }
    try {
      return new URL(text, window.location.origin).toString();
    } catch {
      return null;
    }
  };
  const decodeClientData = () => {
    const rawClientData = window.clientData;
    if (rawClientData == null) {
      fail("window.clientData 缺失");
    }
    if (typeof rawClientData === "string") {
      try {
        return JSON.parse(decodeURIComponent(rawClientData));
      } catch (error) {
        fail(`window.clientData 解析失败: ${error.message}`);
      }
    }
    if (typeof rawClientData === "object" && !Array.isArray(rawClientData)) {
      return rawClientData;
    }
    fail("window.clientData 格式异常");
  };

  const clientData = expectObject(decodeClientData(), "clientData 格式异常");
  const page = expectObject(clientData.page, "clientData.page 缺失");
  const request = expectObject(clientData.request, "clientData.request 缺失");
  const favouriteAds = expectObject(clientData.favouriteAds, "clientData.favouriteAds 缺失");
  const adverts = expectArray(favouriteAds.adverts, "clientData.favouriteAds.adverts 缺失");
  const userData = clientData.userData && typeof clientData.userData === "object" && !Array.isArray(clientData.userData)
    ? clientData.userData
    : null;

  const items = adverts.flatMap((item) => {
    if (!item || typeof item !== "object" || Array.isArray(item)) {
      return [];
    }

    const attributes = Array.isArray(item.attributes)
      ? item.attributes.flatMap((attribute) => {
          if (!attribute || typeof attribute !== "object" || Array.isArray(attribute)) {
            return [];
          }
          const name = optionalString(attribute.name);
          const value = optionalString(attribute.value);
          if (!name || !value) {
            return [];
          }
          return [{
            name,
            value,
            key: optionalString(attribute.key),
            unit: optionalString(attribute.unit),
          }];
        })
      : [];

    const additionalImageUrls = Array.isArray(item.additionalImageUrls)
      ? item.additionalImageUrls.map((url) => toAbsoluteUrl(url)).filter(Boolean)
      : [];

    const url = toAbsoluteUrl(item.path);
    const title = optionalString(item.title);
    const listingId = optionalFiniteNumber(item.id);
    if (!title && !url && listingId == null) {
      return [];
    }

    return [{
      listing_id: listingId == null ? null : String(listingId),
      title,
      description: optionalString(item.shortDescription),
      price: optionalString(item.price),
      location: optionalString(item.location),
      url,
      image_url: toAbsoluteUrl(item.imageUrl),
      additional_image_urls: additionalImageUrls,
      age: optionalString(item.postedDate),
      posted_timestamp: optionalFiniteNumber(item.date),
      number_of_images: optionalFiniteNumber(item.numberOfImages),
      category_id: optionalFiniteNumber(item.categoryId),
      top_level_category_id: optionalFiniteNumber(item.l1CategoryId),
      subcategory_level_2_id: optionalFiniteNumber(item.l2CategoryId),
      status: optionalString(item.status),
      featured: optionalBoolean(item.featured),
      urgent: optionalBoolean(item.urgent),
      standout: optionalBoolean(item.standout),
      bumpup: optionalBoolean(item.bumpup),
      premium: optionalBoolean(item.premium),
      has_video: optionalBoolean(item.hasVideo),
      seller_id: optionalFiniteNumber(item.sellerId),
      account_id: optionalFiniteNumber(item.accountId),
      seller_is_trade: optionalBoolean(item.proAccount),
      attributes,
    }];
  });

  return JSON.stringify({
    source: "clientData.favouriteAds.adverts",
    meta: {
      page_type: optionalString(page.type),
      page_title: optionalString(page.title),
      current_path: optionalString(request.path),
      current_url: window.location.href,
      user_logged_in: optionalBoolean(userData?.userLoggedIn),
      user_id: optionalFiniteNumber(userData?.id),
      user_name: optionalString([userData?.firstName, userData?.lastName].filter(Boolean).join(" ")),
      total_favourites: optionalFiniteNumber(favouriteAds.totalAdverts),
      show_saved_ads: optionalBoolean(favouriteAds.showSavedAds),
    },
    items,
  });
})()
"""


def run_browser_favourites(
    page: BridgePage,
    limit: int = 10,
) -> dict[str, Any]:
    favourites_url = build_favourites_url()
    page.navigate(favourites_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    raw = page.evaluate(_EXTRACT_FAVOURITES_JS)
    if not raw:
        raise BrowserAutomationError("未能从 Gumtree 收藏页提取结果")

    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("收藏页结果格式异常")

    meta = payload.get("meta")
    if not isinstance(meta, dict):
        raise BrowserAutomationError("收藏页元信息格式异常")

    items = payload.get("items")
    if not isinstance(items, list):
        raise BrowserAutomationError("收藏页数据格式异常")

    display_item_fields = (
        "title",
        "description",
        "price",
        "location",
        "url",
        "image_url",
        "additional_image_urls",
        "age",
        "posted_timestamp",
        "number_of_images",
        "status",
        "featured",
        "urgent",
        "standout",
        "bumpup",
        "premium",
        "has_video",
        "seller_is_trade",
        "attributes",
    )

    cleaned = []
    for item in items:
        if not isinstance(item, dict):
            continue
        cleaned.append({field: item.get(field) for field in display_item_fields})

    limited = cleaned[:limit]
    return {
        "ok": True,
        "mode": "browser",
        "source": payload.get("source"),
        "page_type": meta.get("page_type"),
        "page_title": meta.get("page_title"),
        "current_url": meta.get("current_url"),
        "user_logged_in": meta.get("user_logged_in"),
        "user_id": meta.get("user_id"),
        "user_name": meta.get("user_name"),
        "favourites_url": favourites_url,
        "items": limited,
        "total": len(limited),
        "total_favourites": meta.get("total_favourites"),
        "show_saved_ads": meta.get("show_saved_ads"),
    }


path: scripts/gumtree/browser_urls.py
content: from __future__ import annotations

from urllib.parse import urlencode

DEFAULT_SEARCH_LOCATION = "uk"
DEFAULT_SEARCH_CATEGORY = "all"


def _join_values(values: list[str] | None) -> str | None:
    if not values:
        return None
    cleaned = [value.strip() for value in values if value.strip()]
    if not cleaned:
        return None
    return ",".join(cleaned)


def build_search_url(
    keyword: str,
    search_location: str = DEFAULT_SEARCH_LOCATION,
    search_category: str = DEFAULT_SEARCH_CATEGORY,
    sort: str | None = None,
    distance: int | None = None,
    min_price: int | None = None,
    max_price: int | None = None,
    conditions: list[str] | None = None,
    seller_types: list[str] | None = None,
    mobile_storage_capacity: str | None = None,
    common_for_sale_colour: str | None = None,
    mobile_model_apple: str | None = None,
) -> str:
    params: dict[str, str | int] = {
        "search_category": search_category,
        "q": keyword,
        "search_term_populated_by": "input",
        "keyword_correction": "suggest",
        "search_location": search_location,
    }
    optional_params: dict[str, str | int | None] = {
        "sort": sort,
        "distance": distance,
        "min_price": min_price,
        "max_price": max_price,
        "common_for_sale_condition": _join_values(conditions),
        "seller_type": _join_values(seller_types),
        "mobile_storage_capacity": mobile_storage_capacity,
        "common_for_sale_colour": common_for_sale_colour,
        "mobile_model_apple": mobile_model_apple,
    }
    for key, value in optional_params.items():
        if value not in (None, ""):
            params[key] = value

    params = urlencode(params)
    return f"https://www.gumtree.com/search?{params}"


def build_home_url() -> str:
    return "https://www.gumtree.com/"


def build_favourites_url() -> str:
    return "https://www.gumtree.com/my-account/favourites"


def build_category_suggest_url(keyword: str) -> str:
    params = urlencode({"input": keyword})
    return f"https://my.gumtree.com/api/category/suggest?{params}"


def build_post_ad_create_url(category_id: int) -> str:
    return f"https://www.gumtree.com/postad/create?categoryId={category_id}"


def build_messages_url(conversation_id: str | None = None, open_chat: bool = True) -> str:
    params: dict[str, str] = {}
    if conversation_id:
        params["conversationId"] = conversation_id
    if open_chat:
        params["openChat"] = "true"
    if not params:
        return "https://www.gumtree.com/manage/messages"
    return f"https://www.gumtree.com/manage/messages?{urlencode(params)}"


path: scripts/gumtree/errors.py
content: from __future__ import annotations


class GumtreeError(Exception):
    """Base error for gumtree-skills."""


class BridgeError(GumtreeError):
    """Local bridge or extension communication failed."""


class BrowserAutomationError(GumtreeError):
    """Browser-driven Gumtree automation failed."""


path: scripts/gumtree/browser_detail.py
content: from __future__ import annotations

import json
from typing import Any

from .bridge import BridgePage
from .errors import BrowserAutomationError

_EXTRACT_DETAIL_PAGE_JS = """
(() => {
  const fail = (message) => {
    throw new Error(message);
  };
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const expectObject = (value, message) => {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectArray = (value, message) => {
    if (!Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectString = (value, message) => {
    const text = normalizeText(value);
    if (!text) {
      fail(message);
    }
    return text;
  };
  const expectFiniteNumber = (value, message) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      fail(message);
    }
    return value;
  };
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalFiniteNumber = (value) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      return null;
    }
    return value;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const expectBoolean = (value, message) => {
    if (typeof value !== "boolean") {
      fail(message);
    }
    return value;
  };
  const toAbsoluteUrl = (value, message) => {
    const text = expectString(value, message);
    try {
      return new URL(text, window.location.origin).toString();
    } catch {
      fail(`URL 格式异常: ${text}`);
    }
  };
  const queryText = (selector, message) => {
    const node = document.querySelector(selector);
    if (!node) {
      fail(message);
    }
    return expectString(node.textContent, message);
  };
  const queryOptionalText = (selector) => {
    const node = document.querySelector(selector);
    if (!node) {
      return null;
    }
    return optionalString(node.textContent);
  };

  const dataLayerEntries = expectArray(window.dataLayer, "window.dataLayer 缺失");
  const vipViewEvent = dataLayerEntries.find(
    (entry) => entry && typeof entry === "object" && entry.name === "VipViewEvent"
  );
  const vipEvent = expectObject(vipViewEvent, "未找到 VipViewEvent 数据");
  const pageMeta = expectObject(vipEvent.p, "VipViewEvent.p 缺失");

  const gumtreeDataLayer = expectArray(window.gumtreeDataLayer, "window.gumtreeDataLayer 缺失");
  const pageDeclaration = gumtreeDataLayer.find(
    (entry) => entry && typeof entry === "object" && entry.event === "pageDeclaration"
  );
  const detailEvent = expectObject(pageDeclaration, "未找到 gumtreeDataLayer pageDeclaration 事件");
  const listingDetails = expectObject(detailEvent.listingDetails, "pageDeclaration.listingDetails 缺失");
  const sellerProfile = expectObject(detailEvent.sellerProfile, "pageDeclaration.sellerProfile 缺失");

  const attributes = {};
  const attributesContainer = document.querySelector('[data-q="attribute-container"]');
  if (attributesContainer) {
    const attributeRows = Array.from(attributesContainer.querySelectorAll('[data-q="attribute-row"]'));
    for (const row of attributeRows) {
      const keyNode = row.querySelector("dt");
      const valueNode = row.querySelector('[data-testid="attribute-value"]');
      const key = optionalString(keyNode?.textContent);
      const value = optionalString(valueNode?.textContent);
      if (key && value) {
        attributes[key] = value;
      }
    }
  }

  const imageUrls = Array.from(document.querySelectorAll('[data-q="image-carousel"] img'))
    .map((node) => {
      const src = optionalString(node.getAttribute("src"));
      if (!src) {
        return null;
      }
      try {
        return new URL(src, window.location.origin).toString();
      } catch {
        return null;
      }
    })
    .filter(Boolean);
  const uniqueImageUrls = Array.from(new Set(imageUrls));

  const item = {
    title: queryText('[data-q="vip-title"]', "详情页标题缺失"),
    description: queryOptionalText('p[itemProp="description"]'),
    price: queryOptionalText('[data-q="ad-price"]'),
    location: queryOptionalText('[data-q="ad-location"]') || optionalString(listingDetails.location),
    url: window.location.href,
    age: optionalString(listingDetails.age),
    number_of_images: optionalFiniteNumber(listingDetails.numberOfImages),
    category: optionalString(listingDetails.category),
    promotions: Array.isArray(listingDetails.promotions) ? listingDetails.promotions : [],
    seller_name:
      queryOptionalText('[data-q="seller-name"][data-testid="seller-name"], [data-q="seller-name-large-screen"]'),
    seller_last_active: optionalString(sellerProfile.lastActive),
    seller_posting_for: optionalString(sellerProfile.postingFor),
    seller_rating_count: optionalFiniteNumber(sellerProfile.ratingCount),
    seller_rating_source: optionalString(sellerProfile.ratingSource),
    contact_by_email: optionalBoolean(listingDetails.contactEmail),
    contact_by_phone: optionalBoolean(listingDetails.contactNumber),
    is_trade: optionalBoolean(listingDetails.isTrade),
    gbg_verified: optionalBoolean(listingDetails.isGBGVerified),
    delivery_available: optionalBoolean(listingDetails.isDelivery),
    video_link: optionalBoolean(listingDetails.videoLink),
    website_link: optionalBoolean(listingDetails.websiteLink),
    location_show_on_map: optionalBoolean(listingDetails.locationShowOnMap),
    number_words_description: optionalFiniteNumber(listingDetails.numberWordsDescription),
    image_urls: uniqueImageUrls,
    attributes,
  };

  return JSON.stringify({
    source: "strict-detail-page",
    meta: {
      page_type: optionalString(pageMeta.t),
      platform: optionalString(pageMeta.pl),
      request_id: optionalString(detailEvent.requestId),
    },
    item,
  });
})()
"""


def run_browser_detail(
    page: BridgePage,
    detail_url: str,
) -> dict[str, Any]:
    page.navigate(detail_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    raw = page.evaluate(_EXTRACT_DETAIL_PAGE_JS)
    if not raw:
        raise BrowserAutomationError("未能从 Gumtree 详情页提取结果")

    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("详情页结果格式异常")

    meta = payload.get("meta")
    if not isinstance(meta, dict):
        raise BrowserAutomationError("详情页元信息格式异常")

    item = payload.get("item")
    if not isinstance(item, dict):
        raise BrowserAutomationError("详情页数据格式异常")

    display_item_fields = (
        "title",
        "description",
        "price",
        "location",
        "url",
        "age",
        "number_of_images",
        "category",
        "promotions",
        "seller_name",
        "seller_last_active",
        "seller_posting_for",
        "seller_rating_count",
        "seller_rating_source",
        "contact_by_email",
        "contact_by_phone",
        "is_trade",
        "gbg_verified",
        "delivery_available",
        "video_link",
        "website_link",
        "location_show_on_map",
        "image_urls",
        "attributes",
    )

    cleaned = {field: item.get(field) for field in display_item_fields}
    return {
        "ok": True,
        "mode": "browser",
        "source": payload.get("source"),
        "detail_url": detail_url,
        "page_type": meta.get("page_type"),
        "platform": meta.get("platform"),
        "request_id": meta.get("request_id"),
        "item": cleaned,
    }


path: scripts/gumtree/browser_messages.py
content: from __future__ import annotations

import json
import time
from typing import Any

from .bridge import BridgePage
from .browser_auth import _extract_login_status_from_current_page
from .browser_urls import build_messages_url
from .errors import BrowserAutomationError

_CLICK_DETAIL_MESSAGE_BUTTON_JS = """
(() => {
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const candidates = Array.from(document.querySelectorAll(
    'button[data-q="contact-email"], .seller-contact__button-group button, button'
  ));
  const button = candidates.find((node) => {
    if (!(node instanceof HTMLButtonElement)) {
      return false;
    }
    const text = normalizeText(node.textContent).toLowerCase();
    return node.getAttribute("data-q") === "contact-email" || text === "message";
  });
  if (!button) {
    throw new Error("详情页未找到 Message 按钮");
  }
  if (button.disabled) {
    throw new Error("详情页 Message 按钮不可点击");
  }
  button.click();
  return JSON.stringify({
    ok: true,
    button_text: normalizeText(button.textContent),
    button_data_q: button.getAttribute("data-q"),
  });
})()
"""

_EXTRACT_MESSAGES_API_JS = """
(async () => {
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const optionalFiniteNumber = (value) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      return null;
    }
    return value;
  };

  const url = new URL(window.location.href);
  const selectedConversationId = url.searchParams.get("conversationId");
  const response = await fetch("/bff-api/message-centre/conversations", {
    method: "GET",
    credentials: "include",
    headers: {
      "Accept": "application/json",
    },
  });

  if (response.status === 401 || response.status === 403) {
    return JSON.stringify({
      source: "message-centre-api",
      auth_required: true,
      status_code: response.status,
      ok: false,
      error: `message-centre API 返回未授权状态 ${response.status}`,
    });
  }
  if (!response.ok) {
    return JSON.stringify({
      source: "message-centre-api",
      auth_required: null,
      status_code: response.status,
      ok: false,
      error: `message-centre API 返回异常状态 ${response.status}`,
    });
  }

  const payload = await response.json();
  const rawConversations = Array.isArray(payload?.conversations) ? payload.conversations : [];
  const conversations = rawConversations.map((item) => {
    const messages = Array.isArray(item?.messages) ? item.messages : [];
    const lastMessage = messages.length ? messages[messages.length - 1] : null;
    return {
      conversation_id: optionalString(item?.id),
      ad_id: optionalFiniteNumber(item?.adId),
      seller_id: optionalFiniteNumber(item?.sellerId),
      buyer_id: optionalFiniteNumber(item?.buyerId),
      created_at: optionalString(item?.createdAt),
      updated_at: optionalString(item?.updatedAt),
      unread_messages_count: optionalFiniteNumber(item?.unreadMessagesCount),
      is_blocked: optionalBoolean(item?.isBlocked),
      title: null,
      preview: optionalString(lastMessage?.text),
      last_activity: optionalString(lastMessage?.createdAt) || optionalString(item?.updatedAt),
      is_selected: optionalString(item?.id) === selectedConversationId,
      thread_messages: messages.map((message) => ({
        id: optionalString(message?.id),
        text: optionalString(message?.text),
        created_at: optionalString(message?.createdAt),
        updated_at: optionalString(message?.updatedAt),
        user_role: optionalString(message?.userRole),
        user_id: optionalFiniteNumber(message?.userId),
        attachments: Array.isArray(message?.attachments) ? message.attachments : [],
      })),
    };
  });

  const selectedConversation =
    conversations.find((item) => item.is_selected) ||
    conversations[0] ||
    null;

  return JSON.stringify({
    source: "message-centre-api",
    auth_required: false,
    status_code: response.status,
    ok: true,
    meta: {
      current_url: window.location.href,
      selected_conversation_id: selectedConversationId,
      open_chat: url.searchParams.get("openChat") === "true",
    },
    conversation_count: conversations.length,
    conversations: conversations.map((item) => ({
      conversation_id: item.conversation_id,
      ad_id: item.ad_id,
      seller_id: item.seller_id,
      buyer_id: item.buyer_id,
      created_at: item.created_at,
      updated_at: item.updated_at,
      unread_messages_count: item.unread_messages_count,
      is_blocked: item.is_blocked,
      title: item.title,
      preview: item.preview,
      last_activity: item.last_activity,
      is_selected: item.is_selected,
      thread_messages: item.thread_messages,
      thread_message_count: item.thread_messages.length,
    })),
    selected_conversation: selectedConversation ? {
      conversation_id: selectedConversation.conversation_id,
      ad_id: selectedConversation.ad_id,
      seller_id: selectedConversation.seller_id,
      buyer_id: selectedConversation.buyer_id,
      counterpart_name: null,
      listing_title: null,
      listing_price: null,
      listing_location: null,
      is_blocked: selectedConversation.is_blocked,
      unread_messages_count: selectedConversation.unread_messages_count,
      thread_messages: selectedConversation.thread_messages,
      thread_message_count: selectedConversation.thread_messages.length,
      composer: null,
    } : {
      conversation_id: selectedConversationId,
      counterpart_name: null,
      listing_title: null,
      listing_price: null,
      listing_location: null,
      is_blocked: null,
      unread_messages_count: null,
      thread_messages: [],
      thread_message_count: 0,
      composer: null,
    },
  });
})()
"""

_EXTRACT_MESSAGES_PAGE_JS = """
(() => {
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const toAbsoluteUrl = (value) => {
    const text = normalizeText(value);
    if (!text) {
      return null;
    }
    try {
      return new URL(text, window.location.origin).toString();
    } catch {
      return null;
    }
  };
  const toConversationId = (value) => {
    const url = toAbsoluteUrl(value);
    if (!url) {
      return null;
    }
    try {
      return new URL(url).searchParams.get("conversationId");
    } catch {
      return null;
    }
  };
  const isVisible = (el) => {
    if (!(el instanceof HTMLElement)) return false;
    if (el.hidden) return false;
    const style = window.getComputedStyle(el);
    if (style.display === "none" || style.visibility === "hidden") return false;
    return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
  };
  const collectLeafTexts = (root) => {
    if (!(root instanceof HTMLElement)) {
      return [];
    }
    const nodes = Array.from(root.querySelectorAll("div, p, span, a, button, li, h1, h2, h3, h4"));
    const texts = [];
    for (const node of nodes) {
      if (!(node instanceof HTMLElement) || !isVisible(node)) {
        continue;
      }
      const text = normalizeText(node.textContent);
      if (!text) {
        continue;
      }
      const childText = Array.from(node.children)
        .map((child) => normalizeText(child.textContent))
        .filter(Boolean)
        .join(" ");
      if (childText && childText === text) {
        continue;
      }
      texts.push(text);
    }
    return texts;
  };
  const queryText = (root, selector) => {
    if (!(root instanceof Element)) {
      return null;
    }
    const node = root.querySelector(selector);
    return optionalString(node?.textContent);
  };
  const isTimeText = (text) => {
    return /^(today|yesterday|mon|tue|wed|thu|fri|sat|sun)\\b/i.test(text)
      || /^\\d{1,2}:\\d{2}(?:\\s?[ap]m)?$/i.test(text)
      || /^\\d{1,2}(?:st|nd|rd|th)?\\s+[a-z]+$/i.test(text);
  };
  const controlTexts = new Set([
    "Hide",
    "More options",
    "Delete conversation",
    "Upload Image",
    "Make an offer",
  ]);
  const isNoiseText = (text) => {
    if (!text) {
      return true;
    }
    if (controlTexts.has(text)) {
      return true;
    }
    if (/^(block|report)\\b/i.test(text)) {
      return true;
    }
    return false;
  };
  const textarea = document.querySelector('textarea#message-field, textarea[name="message-field"]');
  const submitButton = document.querySelector('button[data-q="message-centre-submit"][aria-label="send"], button[data-q="message-centre-submit"]');
  const composeForm = textarea ? textarea.closest("form") : null;
  let conversationPanel = composeForm ? composeForm.parentElement : null;
  for (let i = 0; conversationPanel && i < 6; i += 1) {
    if (
      conversationPanel.querySelector?.('button[data-q="message-centre-submit"]')
      && /more options/i.test(normalizeText(conversationPanel.textContent))
    ) {
      break;
    }
    conversationPanel = conversationPanel.parentElement;
  }

  const url = new URL(window.location.href);
  const selectedConversationId = url.searchParams.get("conversationId");

  const dataLayerEntries = Array.isArray(window.dataLayer) ? window.dataLayer : [];
  const loadMessagesEvent = dataLayerEntries.find(
    (entry) => entry && typeof entry === "object" && entry.name === "LoadMessagesEvent"
  ) || null;
  const gumtreeDataLayer = Array.isArray(window.gumtreeDataLayer) ? window.gumtreeDataLayer : [];
  const pageDeclaration = gumtreeDataLayer.find(
    (entry) => entry && typeof entry === "object" && entry.event === "pageDeclaration"
  ) || null;

  const conversationAnchors = Array.from(document.querySelectorAll('a[data-q="conversation-link"]'));
  const conversations = conversationAnchors.map((anchor) => {
    const href = anchor.getAttribute("href");
    const conversationId = toConversationId(href);
    const title = queryText(anchor, "h6") || queryText(anchor, '[class*="e15e95g53"]');
    const preview = queryText(anchor, 'p[class*="e15e95g52"]');
    const lastActivity = queryText(anchor, 'p[class*="e15e95g51"]');
    const adState = queryText(anchor, '[class*="e15e95g55"]');
    return {
      conversation_id: conversationId,
      url: toAbsoluteUrl(href),
      title,
      preview,
      last_activity: lastActivity,
      ad_state: adState,
      is_selected: conversationId != null && conversationId === selectedConversationId,
      raw_text: normalizeText(anchor.textContent),
    };
  });

  const counterpartName =
    queryText(conversationPanel, '.css-1i61i7z')
    || queryText(conversationPanel, '[class*="ee7da151"]')
    || null;
  const listingCard =
    conversationPanel?.querySelector('.css-z0xpcq.ec8s6h31')
    || conversationPanel?.querySelector('[class*="ec8s6h31"]')
    || document.querySelector('.css-z0xpcq.ec8s6h31')
    || document.querySelector('[class*="ec8s6h31"]')
    || null;
  const listingTitle =
    queryText(listingCard, '.css-1a7pxff')
    || queryText(listingCard, '[class*="ec8s6h36"]')
    || null;
  const listingPrice =
    queryText(listingCard, '.css-f8asvg')
    || queryText(listingCard, '[class*="eiwoz5f1"]')
    || null;
  const listingLocation =
    queryText(listingCard, '.css-3e1s6r')
    || queryText(listingCard, '[class*="ec8s6h32"]')
    || null;

  const messageNodes = conversationPanel
    ? Array.from(conversationPanel.querySelectorAll('div[id].css-1l95blj, div[id][class*="e1b1ybr611"]'))
    : [];
  const threadMessages = messageNodes.map((node) => ({
    id: optionalString(node.getAttribute("id")),
    text:
      queryText(node, '.css-uqea2t')
      || queryText(node, '[class*="e1b1ybr610"]')
      || null,
    timestamp:
      queryText(node, '[data-testid="timestamp"]')
      || queryText(node, '.css-1gbzq6y')
      || null,
  })).filter((item) => item.text);

  const inferredSelectedConversation = (
    conversations.find((item) => item.is_selected) ||
    (counterpartName
      ? conversations.find((item) => item.title === counterpartName)
      : null) ||
    conversations[0] ||
    null
  );
  const resolvedSelectedConversationId =
    selectedConversationId || inferredSelectedConversation?.conversation_id || null;
  const normalizedConversations = conversations.map((item) => ({
    ...item,
    is_selected: item.conversation_id != null && item.conversation_id === resolvedSelectedConversationId,
  }));

  return JSON.stringify({
    source: "strict-message-centre",
    meta: {
      current_url: window.location.href,
      page_type: optionalString(loadMessagesEvent?.p?.t) || optionalString(pageDeclaration?.page?.legacyType),
      platform: optionalString(loadMessagesEvent?.p?.pl),
      logged_in: optionalBoolean(pageDeclaration?.legacy?.loggedIn),
      selected_conversation_id: resolvedSelectedConversationId,
      open_chat: url.searchParams.get("openChat") === "true",
    },
    conversation_count: conversations.length,
    conversations: normalizedConversations,
    selected_conversation: {
      conversation_id: resolvedSelectedConversationId,
      counterpart_name: counterpartName,
      listing_title: listingTitle,
      listing_price: listingPrice,
      listing_location: listingLocation,
      thread_messages: threadMessages,
      thread_message_count: threadMessages.length,
      composer: {
        can_send: Boolean(textarea && submitButton),
        message_placeholder: optionalString(textarea?.getAttribute("placeholder")),
        draft_length: textarea instanceof HTMLTextAreaElement ? textarea.value.length : null,
      },
    },
  });
})()
"""

_SEND_MESSAGE_JS = """
((messageText) => {
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const text = normalizeText(messageText);
  if (!text) {
    throw new Error("消息内容不能为空");
  }
  const textarea = document.querySelector('textarea#message-field, textarea[name="message-field"]');
  if (!(textarea instanceof HTMLTextAreaElement)) {
    throw new Error("未找到消息输入框");
  }
  const button = document.querySelector('button[data-q="message-centre-submit"][aria-label="send"], button[data-q="message-centre-submit"]');
  if (!(button instanceof HTMLButtonElement)) {
    throw new Error("未找到发送按钮");
  }
  const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
  if (!setter) {
    throw new Error("无法设置消息输入框内容");
  }
  textarea.focus();
  setter.call(textarea, text);
  textarea.dispatchEvent(new Event("input", { bubbles: true }));
  textarea.dispatchEvent(new Event("change", { bubbles: true }));
  textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "H", bubbles: true }));
  textarea.dispatchEvent(new KeyboardEvent("keyup", { key: "H", bubbles: true }));
  if (button.disabled) {
    throw new Error("填写消息后发送按钮仍然不可用");
  }
  button.click();
  return JSON.stringify({
    ok: true,
    message: text,
    button_disabled_before_click: button.disabled,
  });
})
"""

_MESSAGE_CENTRE_READY_JS = """
(() => {
  return window.location.pathname.includes("/manage/messages")
    && !!document.querySelector('textarea#message-field, textarea[name="message-field"]');
})()
"""


def _evaluate_json(page: BridgePage, expression: str) -> dict[str, Any]:
    raw = page.evaluate(expression)
    if not raw:
        raise BrowserAutomationError("浏览器返回了空结果")
    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("浏览器结果格式异常")
    return payload


def _extract_messages_dom_state(page: BridgePage) -> dict[str, Any]:
    payload = _evaluate_json(page, _EXTRACT_MESSAGES_PAGE_JS)
    if not isinstance(payload.get("meta"), dict):
        raise BrowserAutomationError("站内消息页元信息格式异常")
    if not isinstance(payload.get("selected_conversation"), dict):
        raise BrowserAutomationError("站内消息页会话信息格式异常")
    return payload


def _extract_messages_state(page: BridgePage) -> dict[str, Any]:
    api_error: str | None = None
    try:
        payload = _evaluate_json(page, _EXTRACT_MESSAGES_API_JS)
        if payload.get("ok") is True:
            if not isinstance(payload.get("meta"), dict):
                raise BrowserAutomationError("站内消息 API 元信息格式异常")
            if not isinstance(payload.get("selected_conversation"), dict):
                raise BrowserAutomationError("站内消息 API 会话信息格式异常")
            try:
                dom_payload = _extract_messages_dom_state(page)
            except BrowserAutomationError:
                dom_payload = None
            if isinstance(dom_payload, dict):
                payload["dom_source"] = dom_payload.get("source")
                payload["meta"] = {
                    **dom_payload.get("meta", {}),
                    **payload.get("meta", {}),
                }
                payload_conversations = payload.get("conversations")
                dom_selected_conversation = dom_payload.get("selected_conversation", {})
                dom_meta = dom_payload.get("meta", {})
                resolved_selected_conversation_id = None
                if isinstance(dom_selected_conversation, dict):
                    resolved_selected_conversation_id = dom_selected_conversation.get("conversation_id")
                if not resolved_selected_conversation_id and isinstance(dom_meta, dict):
                    resolved_selected_conversation_id = dom_meta.get("selected_conversation_id")

                selected_conversation = payload.get("selected_conversation", {})
                if (
                    resolved_selected_conversation_id
                    and isinstance(payload_conversations, list)
                ):
                    matched_api_conversation = next(
                        (
                            item for item in payload_conversations
                            if isinstance(item, dict) and item.get("conversation_id") == resolved_selected_conversation_id
                        ),
                        None,
                    )
                    if isinstance(matched_api_conversation, dict):
                        selected_conversation = {
                            **selected_conversation,
                            **matched_api_conversation,
                            "conversation_id": resolved_selected_conversation_id,
                        }
                dom_selected_conversation = dom_payload.get("selected_conversation", {})
                if isinstance(selected_conversation, dict) and isinstance(dom_selected_conversation, dict):
                    payload["selected_conversation"] = {
                        **selected_conversation,
                        "counterpart_name": (
                            selected_conversation.get("counterpart_name")
                            or dom_selected_conversation.get("counterpart_name")
                        ),
                        "listing_title": dom_selected_conversation.get("listing_title"),
                        "listing_price": dom_selected_conversation.get("listing_price"),
                        "listing_location": dom_selected_conversation.get("listing_location"),
                        "thread_messages": dom_selected_conversation.get("thread_messages") or selected_conversation.get("thread_messages"),
                        "thread_message_count": dom_selected_conversation.get("thread_message_count") or selected_conversation.get("thread_message_count"),
                        "composer": dom_selected_conversation.get("composer"),
                    }
                dom_conversations = dom_payload.get("conversations")
                api_conversations = payload.get("conversations")
                if isinstance(dom_conversations, list) and isinstance(api_conversations, list):
                    dom_by_id = {
                        item.get("conversation_id"): item
                        for item in dom_conversations
                        if isinstance(item, dict) and item.get("conversation_id")
                    }
                    merged_conversations = []
                    for item in api_conversations:
                        if not isinstance(item, dict):
                            merged_conversations.append(item)
                            continue
                        dom_item = dom_by_id.get(item.get("conversation_id"), {})
                        if not isinstance(dom_item, dict):
                            dom_item = {}
                        merged_conversations.append({
                            **item,
                            "title": item.get("title") or dom_item.get("title"),
                            "preview": item.get("preview") or dom_item.get("preview"),
                            "last_activity": item.get("last_activity") or dom_item.get("last_activity"),
                            "ad_state": item.get("ad_state") or dom_item.get("ad_state"),
                            "is_selected": (
                                item.get("conversation_id") == resolved_selected_conversation_id
                                if resolved_selected_conversation_id
                                else bool(item.get("is_selected") or dom_item.get("is_selected"))
                            ),
                            "thread_messages": item.get("thread_messages"),
                            "thread_message_count": item.get("thread_message_count"),
                        })
                    payload["conversations"] = merged_conversations
            return payload
        api_error = str(payload.get("error") or "message-centre API 返回失败")
        if payload.get("auth_required") is True:
            raise BrowserAutomationError("当前未登录 Gumtree，无法访问站内消息 API")
    except BrowserAutomationError:
        raise
    except Exception as exc:
        api_error = str(exc)

    payload = _extract_messages_dom_state(page)
    payload["source"] = f"{payload.get('source', 'strict-message-centre')}-dom-fallback"
    if api_error:
        payload["api_fallback_reason"] = api_error
    return payload


def _wait_for_expression(page: BridgePage, expression: str, timeout: float, interval: float, message: str) -> Any:
    deadline = time.time() + timeout
    while time.time() < deadline:
        result = page.evaluate(expression)
        if result:
            return result
        time.sleep(interval)
    raise BrowserAutomationError(message)


def _wait_until_message_sent(
    page: BridgePage,
    message: str,
    before_count: int,
) -> dict[str, Any]:
    deadline = time.time() + 20
    latest_state: dict[str, Any] | None = None
    normalized_message = " ".join(message.split())
    while time.time() < deadline:
        time.sleep(1)
        page.wait_dom_stable(timeout=5000)
        latest_state = _extract_messages_state(page)
        selected_conversation = latest_state.get("selected_conversation", {})
        if not isinstance(selected_conversation, dict):
            continue
        thread_messages = selected_conversation.get("thread_messages", [])
        if not isinstance(thread_messages, list):
            continue
        latest_text = None
        if thread_messages:
            last_message = thread_messages[-1]
            if isinstance(last_message, dict):
                latest_text = last_message.get("text")
        message_count = selected_conversation.get("thread_message_count")
        if isinstance(message_count, int) and message_count > before_count:
            return latest_state
        if isinstance(latest_text, str) and " ".join(latest_text.split()) == normalized_message:
            return latest_state
        composer = selected_conversation.get("composer")
        if isinstance(composer, dict) and composer.get("draft_length") == 0:
            return latest_state

    raise BrowserAutomationError(
        f"发送消息后未确认成功，最后状态: {json.dumps(latest_state, ensure_ascii=False)}"
    )


def _build_readable_messages_result(state: dict[str, Any], detail_url: str | None = None) -> dict[str, Any]:
    meta = state.get("meta", {})
    if not isinstance(meta, dict):
        meta = {}

    raw_conversations = state.get("conversations", [])
    selected_conversation = state.get("selected_conversation", {})
    if not isinstance(selected_conversation, dict):
        selected_conversation = {}

    raw_thread_messages = selected_conversation.get("thread_messages", [])
    thread_messages: list[dict[str, Any]] = []
    if isinstance(raw_thread_messages, list):
        for message in raw_thread_messages:
            if not isinstance(message, dict):
                continue
            thread_messages.append({
                "message_id": message.get("id"),
                "text": message.get("text"),
                "time": message.get("timestamp") or message.get("created_at"),
                "updated_at": message.get("updated_at"),
                "sender": {
                    "role": message.get("user_role"),
                    "user_id": message.get("user_id"),
                },
                # "attachments": message.get("attachments") if isinstance(message.get("attachments"), list) else [],
            })

    composer = selected_conversation.get("composer", {})
    if not isinstance(composer, dict):
        composer = {}

    selected_conversation_id = selected_conversation.get("conversation_id") or meta.get("selected_conversation_id")
    selected_conversation_id = selected_conversation_id if isinstance(selected_conversation_id, str) else None

    selected_thread_messages = thread_messages

    readable_conversations: list[dict[str, Any]] = []
    if isinstance(raw_conversations, list):
        for item in raw_conversations:
            if not isinstance(item, dict):
                continue
            conversation_id = item.get("conversation_id")
            is_current = bool(
                (selected_conversation_id and conversation_id == selected_conversation_id)
                or item.get("is_selected")
            )
            raw_item_messages = item.get("thread_messages", [])
            item_messages: list[dict[str, Any]] = []
            if isinstance(raw_item_messages, list):
                for message in raw_item_messages:
                    if not isinstance(message, dict):
                        continue
                    item_messages.append({
                        "message_id": message.get("id"),
                        "text": message.get("text"),
                        "time": message.get("timestamp") or message.get("created_at"),
                        "updated_at": message.get("updated_at"),
                        "sender": {
                            "role": message.get("user_role"),
                            "user_id": message.get("user_id"),
                        },
                        # "attachments": message.get("attachments") if isinstance(message.get("attachments"), list) else [],
                    })
            if is_current and selected_thread_messages:
                item_messages = selected_thread_messages
            readable_conversations.append({
                "conversation_id": conversation_id,
                "display_name": item.get("title"),
                "is_current": is_current,
                "latest_message": item.get("preview"),
                "latest_time": item.get("last_activity"),
                "ad_status": item.get("ad_state"),
                "listing_info": {
                    "title": selected_conversation.get("listing_title") if is_current else None,
                    "price": selected_conversation.get("listing_price") if is_current else None,
                    "location": selected_conversation.get("listing_location") if is_current else None,
                },
                "message_count": item.get("thread_message_count") if item.get("thread_message_count") is not None else len(item_messages),
                "messages": item_messages,
                # "composer_info": {
                #     "can_send": composer.get("can_send") if is_current else None,
                #     "placeholder": composer.get("message_placeholder") if is_current else None,
                # },
            })

    result = {
        "ok": True,
        "mode": "browser",
        "source": state.get("source"),
        "meta": {
            "current_url": meta.get("current_url"),
            "api_fallback_reason": state.get("api_fallback_reason"),
        },
        "conversation_count": state.get("conversation_count"),
        "conversations": readable_conversations,
        "sent_message": state.get("sent_message"),
    }
    if detail_url is not None:
        result["detail_url"] = detail_url
    return result


def _send_message_if_requested(page: BridgePage, message: str | None) -> dict[str, Any]:
    state = _extract_messages_state(page)
    if not message:
        return state

    selected_conversation = state.get("selected_conversation", {})
    if not isinstance(selected_conversation, dict):
        raise BrowserAutomationError("站内消息页会话信息格式异常")
    composer = selected_conversation.get("composer", {})
    if not isinstance(composer, dict) or composer.get("can_send") is not True:
        raise BrowserAutomationError("当前会话没有可用的消息输入框或发送按钮")

    thread_message_count = selected_conversation.get("thread_message_count")
    before_count = thread_message_count if isinstance(thread_message_count, int) else 0
    _evaluate_json(page, f"{_SEND_MESSAGE_JS}({json.dumps(message, ensure_ascii=False)})")
    final_state = _wait_until_message_sent(page=page, message=message, before_count=before_count)
    final_state["sent_message"] = {
        "attempted": True,
        "content": " ".join(message.split()),
    }
    return final_state


def run_browser_messages(
    page: BridgePage,
    conversation_id: str | None = None,
    message: str | None = None,
) -> dict[str, Any]:
    page.navigate(build_messages_url(conversation_id=conversation_id, open_chat=conversation_id is not None))
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    state = _send_message_if_requested(page=page, message=message)
    meta = state.get("meta", {})
    if isinstance(meta, dict) and meta.get("logged_in") is False:
        raise BrowserAutomationError("当前未登录 Gumtree，无法访问站内消息页")
    return _build_readable_messages_result(state)


def run_browser_detail_message(
    page: BridgePage,
    detail_url: str,
    message: str | None = None,
) -> dict[str, Any]:
    page.navigate(detail_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    login_status = _extract_login_status_from_current_page(page)
    if login_status.get("logged_in") is not True:
        raise BrowserAutomationError("当前未登录 Gumtree，无法从详情页进入站内消息")

    _evaluate_json(page, _CLICK_DETAIL_MESSAGE_BUTTON_JS)
    _wait_for_expression(
        page,
        _MESSAGE_CENTRE_READY_JS,
        timeout=20,
        interval=1,
        message="点击详情页 Message 后未进入站内交流页",
    )
    page.wait_dom_stable(timeout=15000)

    state = _send_message_if_requested(page=page, message=message)
    return _build_readable_messages_result(state, detail_url=detail_url)


path: scripts/gumtree/browser_search.py
content: from __future__ import annotations

import json
from typing import Any

from .bridge import BridgePage
from .browser_urls import build_search_url
from .errors import BrowserAutomationError

_EXTRACT_SEARCH_RESULTS_JS = """
(() => {
  const fail = (message) => {
    throw new Error(message);
  };
  const normalizeText = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
  const expectObject = (value, message) => {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectArray = (value, message) => {
    if (!Array.isArray(value)) {
      fail(message);
    }
    return value;
  };
  const expectString = (value, message) => {
    if (typeof value !== "string" || !normalizeText(value)) {
      fail(message);
    }
    return normalizeText(value);
  };
  const expectFiniteNumber = (value, message) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      fail(message);
    }
    return value;
  };
  const optionalString = (value) => {
    const text = normalizeText(value);
    return text || null;
  };
  const optionalFiniteNumber = (value) => {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      return null;
    }
    return value;
  };
  const optionalBoolean = (value) => {
    if (typeof value !== "boolean") {
      return null;
    }
    return value;
  };
  const toAbsoluteUrl = (value) => {
    const text = normalizeText(value);
    if (!text) {
      return "";
    }
    try {
      return new URL(text, window.location.origin).toString();
    } catch {
      return "";
    }
  };
  const extractListingIdFromUrl = (url) => {
    const match = url.match(/\\/(\\d+)(?:[/?#]|$)/);
    if (!match) {
      return null;
    }
    return match[1];
  };
  const parseLdJsonListings = () => {
    const nodes = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
    for (const node of nodes) {
      let parsed;
      try {
        parsed = JSON.parse(node.textContent || "");
      } catch {
        continue;
      }
      const entry = Array.isArray(parsed)
        ? parsed.find((item) => item?.["@type"] === "SearchResultsPage")
        : parsed;
      if (!entry || entry["@type"] !== "SearchResultsPage") {
        continue;
      }
      const mainEntity = expectObject(entry.mainEntity, "SearchResultsPage.mainEntity 缺失");
      const itemList = expectArray(mainEntity.itemListElement, "SearchResultsPage.itemListElement 缺失");
      const urlById = new Map();
      for (const item of itemList) {
        if (!item || typeof item !== "object" || Array.isArray(item)) {
          continue;
        }
        const url = toAbsoluteUrl(item.url);
        if (!url) {
          continue;
        }
        const listingId = extractListingIdFromUrl(url);
        if (!listingId) {
          continue;
        }
        urlById.set(listingId, url);
      }
      return urlById;
    }
    return new Map();
  };
  const parseDomDescriptions = () => {
    const descriptionById = new Map();
    const cards = Array.from(document.querySelectorAll('article[data-q="search-result"]'));
    for (const card of cards) {
      const anchor = card.querySelector('a[data-q="search-result-anchor"][href*="/p/"]');
      if (!anchor) {
        continue;
      }
      const href = toAbsoluteUrl(anchor.getAttribute("href"));
      if (!href) {
        continue;
      }
      const listingId = extractListingIdFromUrl(href);
      const descriptionNode = card.querySelector('[data-q="tile-description"] p');
      if (!descriptionNode) {
        continue;
      }
      const description = normalizeText(descriptionNode.textContent);
      if (!description) {
        continue;
      }
      descriptionById.set(listingId, description);
    }
    return descriptionById;
  };

  const dataLayerEntries = expectArray(window.dataLayer, "window.dataLayer 缺失");
  const initialDataLayer = dataLayerEntries.find(
    (entry) => entry && typeof entry === "object" && entry.name === "SrpViewEvent"
  );
  const srpViewEvent = expectObject(initialDataLayer, "未找到 SrpViewEvent 数据");
  const searchMeta = expectObject(srpViewEvent.s, "SrpViewEvent.s 缺失");
  const pageMeta = expectObject(srpViewEvent.p, "SrpViewEvent.p 缺失");

  const gumtreeDataLayer = expectArray(window.gumtreeDataLayer, "window.gumtreeDataLayer 缺失");
  const pageDeclaration = gumtreeDataLayer.find(
    (entry) => entry && typeof entry === "object" && entry.event === "pageDeclaration"
  );
  const pageDeclarationEvent = expectObject(pageDeclaration, "未找到 gumtreeDataLayer pageDeclaration 事件");
  const searchBar = expectObject(pageDeclarationEvent.searchBar, "pageDeclaration.searchBar 缺失");
  const rawListings = expectArray(pageDeclarationEvent.listListingDetails, "pageDeclaration.listListingDetails 缺失");
  const urlById = parseLdJsonListings();
  const descriptionById = parseDomDescriptions();

  const meta = {
    keyword: optionalString(searchMeta.kw),
    page_number: optionalFiniteNumber(searchMeta.pn),
    total_results: optionalFiniteNumber(searchMeta.tr),
    search_category_id: optionalFiniteNumber(searchMeta.dc),
    page_type: optionalString(pageMeta.t),
    platform: optionalString(pageMeta.pl),
    request_id: optionalString(pageDeclarationEvent.requestId),
    search_category_name: optionalString(searchBar.categoryName),
    search_bar_category_id: optionalFiniteNumber(searchBar.categoryId),
    search_location_name: optionalString(searchBar.locationName),
    page_size: optionalFiniteNumber(searchMeta.tp),
  };

  const items = rawListings.flatMap((listing, index) => {
    if (!listing || typeof listing !== "object" || Array.isArray(listing)) {
      return [];
    }
    const row = listing;
    const rawListingId = optionalFiniteNumber(row.id);
    const listingId = rawListingId == null ? extractListingIdFromUrl(toAbsoluteUrl(row.url || row.path)) : String(rawListingId);
    const url = urlById.get(listingId) || null;
    const description = descriptionById.get(listingId) || null;
    const title = optionalString(row.name);
    if (!title && !url && !listingId) {
      return [];
    }

    return [{
      listing_id: listingId,
      position: optionalFiniteNumber(row.position),
      title,
      description,
      price: optionalFiniteNumber(row.price),
      price_pennies: optionalFiniteNumber(row.pricePennies),
      location: optionalString(row.location),
      url,
      age: optionalString(row.age),
      number_of_images: optionalFiniteNumber(row.numberOfImages),
      number_words_description: optionalFiniteNumber(row.numberWordsDescription),
      category: optionalString(row.category),
      category_id: optionalFiniteNumber(row.categoryId),
      subcategory1: optionalFiniteNumber(row.subcategory1),
      subcategory2: optionalFiniteNumber(row.subcategory2),
      subcategory3: optionalFiniteNumber(row.subcategory3),
      subcategory4: optionalFiniteNumber(row.subcategory4),
      promotions: Array.isArray(row.promotions) ? row.promotions : [],
      is_trade: optionalBoolean(row.isTrade),
      video_link: optionalBoolean(row.videoLink),
      is_gbg_verified: optionalBoolean(row.isGBGVerified),
      is_delivery: optionalBoolean(row.isDelivery),
      source: optionalString(row.source),
      recall_source: optionalString(row.recallSource),
    }];
  });

  return JSON.stringify({
    source: "strict-structured-data",
    meta,
    items,
  });
})()
"""


def run_browser_search(
    page: BridgePage,
    keyword: str,
    limit: int = 10,
    search_location: str = "uk",
    search_category: str = "all",
    sort: str | None = None,
    distance: int | None = None,
    min_price: int | None = None,
    max_price: int | None = None,
    conditions: list[str] | None = None,
    seller_types: list[str] | None = None,
    mobile_storage_capacity: str | None = None,
    common_for_sale_colour: str | None = None,
    mobile_model_apple: str | None = None,
) -> dict[str, Any]:
    display_item_fields = (
        "title",
        "description",
        "price",
        "location",
        "url",
        "age",
        "number_of_images",
        "promotions",
    )
    search_url = build_search_url(
        keyword,
        search_location=search_location,
        search_category=search_category,
        sort=sort,
        distance=distance,
        min_price=min_price,
        max_price=max_price,
        conditions=conditions,
        seller_types=seller_types,
        mobile_storage_capacity=mobile_storage_capacity,
        common_for_sale_colour=common_for_sale_colour,
        mobile_model_apple=mobile_model_apple,
    )
    page.navigate(search_url)
    page.wait_for_load()
    page.wait_dom_stable(timeout=15000)

    raw = page.evaluate(_EXTRACT_SEARCH_RESULTS_JS)
    if not raw:
        raise BrowserAutomationError("未能从 Gumtree 搜索页提取结果")

    payload = json.loads(raw)
    if not isinstance(payload, dict):
        raise BrowserAutomationError("搜索结果格式异常")

    items = payload.get("items")
    if not isinstance(items, list):
        raise BrowserAutomationError("搜索结果格式异常")

    meta = payload.get("meta")
    if not isinstance(meta, dict):
        meta = {}

    cleaned = []
    for item in items:
        if not isinstance(item, dict):
            continue
        cleaned.append({field: item.get(field) for field in display_item_fields})

    limited = cleaned[:limit]
    return {
        "ok": True,
        "mode": "browser",
        "source": payload.get("source"),
        "keyword": meta.get("keyword"),
        "page_number": meta.get("page_number"),
        "total_results": meta.get("total_results"),
        "request_id": meta.get("request_id"),
        "page_type": meta.get("page_type"),
        "platform": meta.get("platform"),
        "search_category_id": meta.get("search_category_id"),
        "search_category_name": meta.get("search_category_name"),
        "search_bar_category_id": meta.get("search_bar_category_id"),
        "search_location_name": meta.get("search_location_name"),
        "page_size": meta.get("page_size"),
        "search_url": search_url,
        "items": limited,
        "total": len(limited),
    }


path: agents/openai.yaml
content: interface:
  display_name: "Gumtree Skills"
  short_description: "Operate Gumtree in a real Chrome session"
  default_prompt: "Use $gumtree-skills to work with Gumtree through the local Chrome bridge for login checks, search, listing inspection, messaging, favourites, or post-ad category selection."


path: extension/background.js
content: const BRIDGE_URL = "ws://localhost:9335";
let ws = null;

chrome.alarms.create("keepAlive", { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener(() => {
  if (!ws || ws.readyState !== WebSocket.OPEN) connect();
});

function connect() {
  if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;

  ws = new WebSocket(BRIDGE_URL);

  ws.onopen = () => {
    console.log("[Gumtree Bridge] 已连接到 bridge server");
    ws.send(JSON.stringify({ role: "extension" }));
  };

  ws.onmessage = async (event) => {
    let msg;
    try {
      msg = JSON.parse(event.data);
    } catch {
      return;
    }

    try {
      const result = await handleCommand(msg);
      ws.send(JSON.stringify({ id: msg.id, result: result ?? null }));
    } catch (err) {
      ws.send(JSON.stringify({ id: msg.id, error: String(err.message || err) }));
    }
  };

  ws.onclose = () => {
    console.log("[Gumtree Bridge] 连接断开，3s 后重连...");
    setTimeout(connect, 3000);
  };

  ws.onerror = (event) => {
    console.error("[Gumtree Bridge] WS 错误", event);
  };
}

async function handleCommand(msg) {
  const { method, params = {} } = msg;

  switch (method) {
    case "navigate":
      return await cmdNavigate(params);
    case "wait_for_load":
      return await cmdWaitForLoad(params);
    case "evaluate":
    case "wait_dom_stable":
    case "get_url":
      return await cmdEvaluateInMainWorld(method, params);
    default:
      throw new Error(`未知命令: ${method}`);
  }
}

async function cmdNavigate({ url }) {
  const tab = await getOrOpenGumtreeTab();
  await chrome.tabs.update(tab.id, { url, active: true });
  await waitForTabComplete(tab.id, url, 60000);
  return null;
}

async function cmdWaitForLoad({ timeout = 60000 }) {
  const tab = await getOrOpenGumtreeTab();
  await waitForTabComplete(tab.id, null, timeout);
  return null;
}

function waitForTabComplete(tabId, expectedUrlPrefix, timeout) {
  return new Promise((resolve, reject) => {
    const deadline = Date.now() + timeout;

    function listener(id, info, updatedTab) {
      if (id !== tabId) return;
      if (info.status !== "complete") return;
      if (expectedUrlPrefix && !updatedTab.url?.startsWith(expectedUrlPrefix.slice(0, 20))) return;
      chrome.tabs.onUpdated.removeListener(listener);
      resolve();
    }

    chrome.tabs.onUpdated.addListener(listener);

    const poll = async () => {
      if (Date.now() > deadline) {
        chrome.tabs.onUpdated.removeListener(listener);
        reject(new Error("页面加载超时"));
        return;
      }
      const tab = await chrome.tabs.get(tabId).catch(() => null);
      if (tab && tab.status === "complete") {
        chrome.tabs.onUpdated.removeListener(listener);
        resolve();
        return;
      }
      setTimeout(poll, 400);
    };

    setTimeout(poll, 600);
  });
}

async function cmdEvaluateInMainWorld(method, params) {
  const tab = await getOrOpenGumtreeTab();
  const results = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    world: "MAIN",
    func: mainWorldExecutor,
    args: [method, params],
  });
  const result = results?.[0]?.result;
  if (result && typeof result === "object" && "__gumtree_error" in result) {
    throw new Error(result.__gumtree_error);
  }
  return result ?? null;
}

async function mainWorldExecutor(method, params) {
  switch (method) {
    case "evaluate": {
      try {
        return await Promise.resolve(
          Function(`"use strict"; return (${params.expression})`)()
        );
      } catch (err) {
        return { __gumtree_error: `JS 执行错误: ${err.message}` };
      }
    }
    case "get_url":
      return window.location.href;
    case "wait_dom_stable": {
      const timeout = params.timeout || 10000;
      const interval = params.interval || 500;
      return new Promise((resolve) => {
        let last = -1;
        const start = Date.now();
        (function tick() {
          const size = document.body ? document.body.innerHTML.length : 0;
          if (size === last && size > 0) {
            resolve(null);
            return;
          }
          last = size;
          if (Date.now() - start >= timeout) {
            resolve(null);
            return;
          }
          setTimeout(tick, interval);
        })();
      });
    }
    default:
      return { __gumtree_error: `未知 MAIN world 方法: ${method}` };
  }
}

async function getOrOpenGumtreeTab() {
  const tabs = await chrome.tabs.query({
    url: [
      "https://www.gumtree.com/*",
      "https://gumtree.com/*"
    ]
  });
  if (tabs.length > 0) return tabs[0];
  const tab = await chrome.tabs.create({ url: "https://www.gumtree.com/" });
  await waitForTabComplete(tab.id, null, 30000);
  return tab;
}

connect();


path: extension/manifest.json
content: {
  "manifest_version": 3,
  "name": "Gumtree Bridge",
  "version": "1.0.0",
  "description": "Gumtree 自动化 Bridge - 连接本地 Python CLI",
  "permissions": [
    "tabs",
    "cookies",
    "scripting",
    "alarms",
    "debugger"
  ],
  "host_permissions": [
    "https://www.gumtree.com/*",
    "https://gumtree.com/*",
    "ws://localhost/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": [
        "https://www.gumtree.com/*",
        "https://gumtree.com/*"
      ],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}


path: extension/content.js
content: chrome.runtime.onMessage.addListener((_msg, _sender, sendResponse) => {
  sendResponse({ result: null });
  return false;
});


path: gumtree_skills/__init__.py
content: """gumtree-skills MVP package."""


path: gumtree_skills/cli.py
content: """gumtree-skills MVP CLI entrypoint."""

from __future__ import annotations

import argparse
import json
import sys
from typing import Any

from scripts.gumtree.errors import BridgeError, BrowserAutomationError, GumtreeError

if sys.stdout and hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
    sys.stderr.reconfigure(encoding="utf-8")


def _output(data: dict[str, Any], exit_code: int = 0) -> None:
    print(json.dumps(data, ensure_ascii=False, indent=2))
    raise SystemExit(exit_code)


def cmd_search(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_search import run_browser_search
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_search(
        page=page,
        keyword=args.keyword,
        limit=args.limit,
        search_location=args.search_location,
        search_category=args.search_category,
        sort=args.sort,
        distance=args.distance,
        min_price=args.min_price,
        max_price=args.max_price,
        conditions=args.condition,
        seller_types=args.seller_type,
        mobile_storage_capacity=args.mobile_storage_capacity,
        common_for_sale_colour=args.common_for_sale_colour,
        mobile_model_apple=args.mobile_model_apple,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_check_login(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_auth import run_browser_check_login
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_check_login(page=page)
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_login(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_auth import run_browser_login
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_login(page=page, username=args.username, [REDACTED_SECRET])
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_logout(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_auth import run_browser_logout
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_logout(page=page)
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_home_recommend(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_home import run_browser_home_recommendations
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_home_recommendations(
        page=page,
        limit=args.limit,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_favourites(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_favourites import run_browser_favourites
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_favourites(
        page=page,
        limit=args.limit,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_detail(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_detail import run_browser_detail
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_detail(
        page=page,
        detail_url=args.url,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_detail_favourite(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_detail_favourite import run_browser_detail_favourite
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_detail_favourite(
        page=page,
        detail_url=args.url,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_post_ad(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_post_ad import run_browser_post_ad_category
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_post_ad_category(
        page=page,
        keyword=args.keyword,
        category_name=args.category_name,
        category_index=args.category_index,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_messages(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_messages import run_browser_messages
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_messages(
        page=page,
        conversation_id=args.conversation_id,
        message=args.message,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def cmd_detail_message(args: argparse.Namespace) -> None:
    from scripts.gumtree.browser_messages import run_browser_detail_message
    from scripts.gumtree.bridge import BridgePage, ensure_bridge_ready

    ensure_bridge_ready()
    page = BridgePage()
    result = run_browser_detail_message(
        page=page,
        detail_url=args.url,
        message=args.message,
    )
    _output(result, exit_code=0 if result.get("ok") else 2)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="gumtree-skills MVP CLI")
    subparsers = parser.add_subparsers(dest="command", required=True)

    sub = subparsers.add_parser("check-login", help="检查 Gumtree 当前登录状态")
    sub.set_defaults(func=cmd_check_login)

    sub = subparsers.add_parser("login", help="通过邮箱和密码登录 Gumtree")
    sub.add_argument("--username", required=True, help="Gumtree 登录邮箱")
    sub.add_argument("--password", required=True, help="Gumtree 登录密码")
    sub.set_defaults(func=cmd_login)

    sub = subparsers.add_parser("logout", help="退出 Gumtree 当前登录状态")
    sub.set_defaults(func=cmd_logout)

    sub = subparsers.add_parser("search", help="通过浏览器扩展搜索 Gumtree 内容")
    sub.add_argument("--keyword", required=True)
    sub.add_argument("--limit", type=int, default=10)
    sub.add_argument("--search-location", default="uk")
    sub.add_argument("--search-category", default="all")
    sub.add_argument(
        "--sort",
        choices=["relevance", "date", "price_lowest_first", "price_highest_first", "distance"],
        help="排序方式：相关性、时间、价格低到高、价格高到低或距离优先",
    )
    sub.add_argument("--distance", type=int, help="搜索半径，单位按 Gumtree 页面参数解释")
    sub.add_argument("--min-price", type=int)
    sub.add_argument("--max-price", type=int)
    sub.add_argument(
        "--condition",
        action="append",
        choices=["as_good_as_new", "good", "new", "fair"],
        help="物品成色筛选；可重复传入，例如 --condition good --condition new",
    )
    sub.add_argument(
        "--seller-type",
        action="append",
        choices=["trade", "private"],
        help="卖家类型筛选；可重复传入，例如 --seller-type trade --seller-type private",
    )
    sub.add_argument("--mobile-storage-capacity")
    sub.add_argument("--common-for-sale-colour")
    sub.add_argument("--mobile-model-apple")
    sub.set_defaults(func=cmd_search)

    sub = subparsers.add_parser("home-recommend", help="提取 Gumtree 首页推荐内容")
    sub.add_argument("--limit", type=int, default=10)
    sub.set_defaults(func=cmd_home_recommend)

    sub = subparsers.add_parser("favourites", help="提取 Gumtree 收藏内容")
    sub.add_argument("--limit", type=int, default=10)
    sub.set_defaults(func=cmd_favourites)

    sub = subparsers.add_parser("detail", help="提取 Gumtree 详情页内容")
    sub.add_argument("--url", required=True)
    sub.set_defaults(func=cmd_detail)

    sub = subparsers.add_parser("detail-favourite", help="收藏 Gumtree 详情页")
    sub.add_argument("--url", required=True)
    sub.set_defaults(func=cmd_detail_favourite)

    sub = subparsers.add_parser("messages", help="查看 Gumtree 站内交流页并可发送消息")
    sub.add_argument("--conversation-id", default=None, help="可选，会话 ID")
    sub.add_argument("--message", default=None, help="可选，要发送的消息内容")
    sub.set_defaults(func=cmd_messages)

    sub = subparsers.add_parser("detail-message", help="从 Gumtree 详情页点击 Message 并进入站内交流页")
    sub.add_argument("--url", required=True)
    sub.add_argument("--message", default=None, help="可选，要发送的消息内容")
    sub.set_defaults(func=cmd_detail_message)

    sub = subparsers.add_parser("post-ad", help="发布二手物品（选择类目）")
    sub.add_argument("--keyword", required=True, help="物品关键词，如 iPhone, Sofa")
    sub.add_argument("--category-name", default=None, help="目标类目名称，模糊匹配建议列表")
    sub.add_argument("--category-index", type=int, default=None, help="目标类目索引（0 起）")
    sub.set_defaults(func=cmd_post_ad)

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()
    try:
        args.func(args)
    except (GumtreeError, BridgeError, BrowserAutomationError) as exc:
        _output({"ok": False, "error": str(exc)}, exit_code=2)
    except Exception as exc:  # pragma: no cover
        _output({"ok": False, "error": f"未知错误: {exc}"}, exit_code=2)


if __name__ == "__main__":
    main()


path: gumtree_skills/__main__.py
content: from .cli import main


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal:  | SkillSpector: suspicious (CRITICAL)