- name
- noon-shared
- description
- Common noon Seller Center mechanics — login (OTP auto-fetch), page-structure URL map, My Catalog read access, common modals, button-click patterns, project-ID discovery. Prerequisite: every other noon-* skill (noon-listing, noon-fbn, noon-exports, noon-ads) expects this loaded for auth and shared UI patterns.
# Noon — Shared (login, navigation, common patterns)
This skill covers what every noon Seller Center task needs:
authentication, the page-structure URL map, My Catalog read access,
and shared UI patterns (modals, button clicks). Operation-specific
skills (`noon-listing`, `noon-fbn`, `noon-exports`, `noon-ads`)
load this first.
## 1. Login — Auto-fetch OTP When Email Is Bound
Noon uses **email OTP only** (no password). Before doing ANYTHING,
decide how the OTP will be retrieved — do NOT bother the user if an
email MCP integration can do it for you.
### Decision flow (follow in order, do NOT skip steps)
1. Open the login page and look at the email it's about to send OTP to:
```bash
browser-use <<'PY'
new_tab("https://login.noon.partners/en/")
wait_for_load()
print(page_info()) # find the email on the "We've sent..." screen
# OR the prefilled channelIdentifier
PY
```
Ziniao often auto-fills or remembers a session. The page shows
`Log in with OTP sent to <email>`.
2. Check whether the task prompt includes a **"## Email System"**
section (the runner injects it when the store has connected
email accounts). Compare the bound emails to the Noon login
email shown on screen:
- **Match** → fetch the OTP yourself via email MCP (Case A).
- **No email system section**, OR **emails do not match** →
ask the user (Case B). Never guess — mismatched emails mean
a different inbox.
### Case A — Auto-fetch OTP via email MCP (preferred)
This works when the bound email address equals the Noon login
email. One round trip, no user prompt:
```python
# 1. Trigger immediate IMAP poll (don't wait 5 min for auto-sync):
vibe_seller_sync_email_now(account_email='<bound-email>')
# 2. Get the per-account DB path — COPY the exact db_path string it
# returns; do NOT hand-build the path from the account id:
vibe_seller_email_info(store_id='<store-id>')
# Returns: { accounts: [{ email, db_path, ... }] }
```
> ⚠️ **Use the `db_path` string verbatim from `email_info` — never
> retype the account UUID into a path.** `sqlite3 <bad-path>` does NOT
> error on a missing file: it **creates a new empty DB and returns zero
> rows**, so a one-character typo in the UUID silently looks like "no OTP
> email arrived." (Live failure: an agent hand-retyped the account UUID
> with two hex digits transposed in the tail — `…b7a` where the real id
> ended `…ba7` — queried the empty DB it just created, found nothing,
> then grepped random 6-digit numbers out of an unrelated dump and
> submitted two wrong codes — see the "Invalid OTP" note below.)
Then read the **latest** Noon OTP email — the one that arrived *after*
you clicked to send the code (noon issues a fresh code on every send /
"Try again", and older codes are already invalid). The body is HTML —
the 6-digit code sits on its own line inside a styled `<div>`. Extract
it from `body_html` (not `body_text`), newest first by `received_epoch`:
```bash
sqlite3 "<db_path-from-email_info>" "SELECT body_html FROM emails \
WHERE sender LIKE '%verify@noon.com%' \
AND subject='Verify your email' \
ORDER BY received_epoch DESC LIMIT 1" \
| python3 -c "
import sys, re
html = sys.stdin.read()
# The OTP is a standalone 6-digit number inside a styled div.
# Reject repeated digits (000000, 333333 etc. — those are hex colors).
for m in re.findall(r'>\s*(\d{6})\s*<', html):
if len(set(m)) > 1:
print(m); break
"
```
If this prints nothing, the DB path is wrong or the sync hasn't landed
— re-run `email_info` (copy the path), re-run `sync_email_now`, and
retry. **Do NOT** fall back to grepping 6-digit numbers out of other
files/dumps; those are IDs/timestamps, not the OTP.
Then fill it and continue:
```bash
browser-use <<'PY'
fill_input("input[name='otp']", "<code>") # OTP field (adjust selector to the live input)
js("document.querySelector('button[type=submit]').click()") # Continue
wait_for_load()
print(page_info())
# dismiss passkey prompt (button has no stable id; match by text):
js("Array.from(document.querySelectorAll('button')).find(b=>/maybe later/i.test(b.textContent))?.click()")
PY
```
> **The programmatic fill WORKS — noon's OTP input is not anti-bot
> protected.** Setting the input via the native
> `HTMLInputElement.value` setter + dispatching `input`/`change` (what
> `fill_input` does) registers fine: Continue enables and the form
> submits. If you enter a **correct, fresh** code it logs you in. Do NOT
> assume the input is blocked.
> **`Invalid OTP` (with a `Trace ID`) means the CODE is wrong or stale —
> NOT that the input was blocked.** The server accepted and validated
> your submission; the value you typed just wasn't the current code. This
> is the #1 noon-login failure and it is almost always one of: (a) you
> read an **empty DB** because the `db_path` was hand-typed with a typo
> (see the ⚠️ above — always copy it from `email_info`); (b) you used a
> **stale** code from a previous send instead of the newest email; or (c)
> you grepped a **random 6-digit number** out of a non-email file. Fix
> the retrieval and re-fill — do NOT conclude "anti-bot" and give up.
> (Live failure this note fixes: an agent queried an empty typo'd DB,
> submitted `127660` then `667599` — neither was a real code — got
> `Invalid OTP` twice, and wrongly reported noon as "anti-bot blocked".)
> **Only escalate to Case B if a verified-fresh, correct code is rejected
> repeatedly** (re-fetched from the right DB, newest email, entered
> exactly) **or** the account genuinely can't receive mail. A scheduled
> unattended run has no human to type the OTP, so a real
> can't-retrieve-the-code situation must surface as an explicit
> "noon needs re-login" escalation, not a silent partial report. Once a
> login succeeds, the Ziniao session cookie persists across later tasks.
### Case B — Ask the user (only when Case A doesn't apply)
Trigger the OTP, then ask via `AskUserQuestion` (which requires
**2+ options**, not 1 — a single-option call fails validation):
```
AskUserQuestion(questions=[{
"question": "I've triggered the Noon OTP email to <email>. Please enter the 6-digit code in the browser, then tell me when you're ready.",
"header": "OTP",
"options": [
{"label": "Entered, continue", "description": "I typed the OTP"},
{"label": "Didn't arrive", "description": "Resend or switch account"}
],
"multiSelect": false
}])
```
If the user can't receive the OTP, don't guess or retry — escalate
and let them resolve.
### After login — discover `{project_id}` from the URL
```bash
browser-use <<'PY'
# dismiss passkey prompt (match by text — no stable id)
js("Array.from(document.querySelectorAll('button')).find(b=>/maybe later/i.test(b.textContent))?.click()")
wait_for_load()
print(page_info()) # URL now carries ?project=PRJ{project_id}
PY
# Redirects to welcome.noon.partners/... with project=PRJ{project_id}
```
**Do NOT ask the user for the project ID** if the store profile
(`stores/<slug>/STORE.md`) doesn't have it. Read it from the post-
login URL — the welcome / store-home page always carries
`?project=PRJ{project_id}` (e.g. `NNNNNN`). Capture it once, then
reuse for the rest of the task. A project's countries share the same
numeric project ID; only the URL country suffix (`/en-<cc>/`) differs.
Optionally persist what you learned back to the store profile:
```
vibe_seller_write_workspace_file(
path="stores/<slug>/metadata.json",
content='{"platform_countries": {"noon": ["EG", "KW"]}, "noon_project_id": "<project_id>"}'
)
```
## 2. Page Structure Cheat Sheet
Most post-login URLs require `?project=PRJ{project_id}` (the login
and welcome pages are the only exceptions). `{project_id}` is the
numeric project identifier. The store identifier in path segments
like `STR{project_id}-N{CC}` reuses the same numeric value with
`STR` prefix and country suffix. Direct URL navigation works for
most pages; sidebar only for Support/Help.
> ⚠️ **Two different country mechanisms — do not assume one from the
> other.** Portals whose path carries `/en-{cc}/` (Ad Manager, FBN) are
> country-scoped **by URL**: navigate and you are in that country.
> Portals whose path is only `/en/` (**My Catalog, Imports, Exports,
> Sales, Transaction View, Vantage**) are scoped by a **sidebar store
> switcher** that persists across sessions — the URL is identical for
> every country, so a page can silently show a DIFFERENT market than the
> one you were just working in.
>
> **Read the flag before trusting any `/en/` page**, and say which
> country the data is from when you report it:
> ```bash
> browser-use <<'PY'
> print(js("""var f=document.querySelector('.ns-side-nav__store-flag');
> return f ? getComputedStyle(f).backgroundImage : 'no switcher';"""))
> # → url("…/images/flags/<cc>.svg")
> PY
> ```
> To switch, click the `.ns-store-list__item` whose `[class*=item-flag]`
> background-image ends in the target `<cc>.svg` — the entries can share
> an identical store NAME across countries, so match on the flag, never
> on the label.
>
> Live failure this prevents: a whole-catalogue stock/live-status audit
> was read off this page while the switcher sat on a neighbouring
> market, and the resulting "these SKUs have stock but no ads" list was
> used to plan campaigns in the *other* country. Nothing in the URL,
> the page title, or the project id revealed the mismatch.
| Page | URL |
|------|-----|
| Create listing | `noon-catalog.noon.partners/en/catalog/create` |
| Edit listing | `noon-catalog.noon.partners/en/catalog/{sku}/d?code={code}&offerTab=noon` |
| My Catalog | `noon-catalog.noon.partners/en/catalog` |
| Catalog Imports | `noon-catalog.noon.partners/en/imports` |
| Catalog Exports | `noon-catalog.noon.partners/en/exports` |
| FBN My ASN & Storage | `fbn.noon.partners/en-{cc}/asn` |
| FBN Create ASN | `fbn.noon.partners/en-{cc}/asn/createasn` |
| FBN My Inventory | `fbn.noon.partners/en-{cc}/inventory` |
| Sales | `reports.noon.partners/en/sales/` |
| Transaction View | `noon-payments.noon.partners/en/transaction-view` |
| Ad Manager | `admanager.noon.partners/en-{cc}/home?mpCode=noon` |
| Campaign Detail | `admanager.noon.partners/en-{cc}/campaign/details/{id}?mpCode=noon` |
| Create Campaign | `admanager.noon.partners/en-{cc}/campaign/start?mpCode=noon` |
| Vantage | `vantage.noon.partners/en/` |
## 3. My Catalog (read access)
**URL**: `https://noon-catalog.noon.partners/en/catalog?project=PRJ{project_id}`
Tabs: `noon` (default), `supermall`, `global`.
Each row has:
- Product title + Brand
- PSKU (Partner SKU) + SKU (noon ID), copyable
- Price, Sale/Promo badge
- Estimated Fees (FBN + FBP separately)
- Active Net Stock (FBN + FBP, links to inventory)
- Performance (Views, Units Sold, Sales)
- Seller Status toggle
- Live Status + "View Issues" link
Click the product title anchor to open the edit page (see
`noon-listing` skill).
### `wait_for_load()` is NOT enough — poll until the rows render
noon's portal is a React/Next app that reaches `readyState:'interactive'`
with `document.body.innerText === ''` and paints seconds later. Right
after `wait_for_load()` you will read an **empty page**: `js(...)` returns
`''`, a row count returns `0`, and a `print()` of it makes the heredoc
emit *nothing at all* (`Bash completed with no output`). None of that
means "no results" — it means "not painted yet". Concluding `0 items`
from it is a silent wrong answer.
Always poll for a content marker before reading anything:
```bash
browser-use <<'PY'
import time
new_tab("https://noon-catalog.noon.partners/en/catalog?project=PRJ{pid}&tab=noon")
wait_for_load()
t = ""
for _ in range(15): # ~45 s worst case
t = js("return document.body.innerText") or ""
if "PSKU" in t: # the marker for THIS page
break
time.sleep(3)
else:
raise SystemExit("catalog never rendered — do not treat as empty")
print(len(t))
PY
```
Pick the marker per page: `PSKU` on My Catalog, `Barcode` on the Offer
tab, `Total`/`items` for a count. Never `time.sleep(n)` once and hope.
### Enumerate a subset via URL params — do NOT fight the search box
The list page's filters and pagination are **URL state**. Set them in
the URL and read the rows; that is exact, resumable, and avoids the
search box entirely:
```
…/en/catalog?project=PRJ{project_id}&tab=noon&live_status=false&page=1
```
| Param | Values | Notes |
|---|---|---|
| `tab` | `noon` / `supermall` / `global` | marketplace, not country |
| `live_status` | `false` / `true` | `false` = Not Live only |
| `page` | 1-based | 20 rows/page; the header reads `N items`, so pages = ⌈N/20⌉ |
Read `N items` first, then walk `page=1…⌈N/20⌉`. **Anchor `href`s carry
the `code=` param you need** for each row's detail page — collect
`(PSKU, href)` while you page, don't reconstruct URLs later (§ below).
> **One page per `browser-use` invocation.** The store wrapper kills any
> single invocation at **120 s** and counts it as a wedge strike (see
> browser-harness § "Budget every invocation"). A render poll can burn
> ~45 s on one page, so three pages in one heredoc overruns the limit and
> gets read as a broken browser. Loop pages in the **shell**, one
> invocation each, appending to a file.
在 GitHub 查看