Skip to content

taobao detail returns an anti-scrape decoy price for detail.tmall.com items — the decoy value is NOT fixed (not always ¥9999) #2162

Description

@zjc627706635-beep

Environment

  • opencli 1.8.6 (Node v24.14.1, Windows 11)
  • Adapter: clis/taobao/detail.js
  • Reproduced with a logged-in Taobao session (opencli taobao whoamilogged_in: true)

Summary

opencli taobao detail <id> returns a decoy price instead of the real price
for items whose detail page lives on detail.tmall.com and is served
through Taobao's "百亿补贴" (Billion Subsidy) channel. The decoy is not a
scraping artifact picked up by mistake — it's a real anti-scrape anchor
value Alibaba's own server bakes directly into the page's SSR hydration
state (window.__ICE_APP_CONTEXT__) when the request doesn't carry a
session-scoped channel token.

Important correction from an earlier draft of this report: the decoy
value is not always the literal ¥9999. Adversarial re-testing against a
wider item sample found decoys of ¥3999, ¥4499, and others — any
round-number anchor the seller/campaign happens to configure. A fix that
pattern-matches a specific literal value (e.g. price === '9999') will
silently pass these through as if they were real prices. The only reliable
signal is structural: the real, final price lives in
componentsVO.priceVO.extraPrice and that key is absent from the SSR
payload on a gated render, regardless of what number ends up in the
price fallback field. A second, narrower bug in the same function also
causes wrong prices on ordinary (non-subsidy) tmall items in some cases.

Reproduction

$ opencli taobao search "罗技鼠标"     # or any query landing on a 百亿补贴 tmall listing
...
$ opencli taobao detail 1009201607950
- field: 价格
  value: ¥9999

Search-page price for the same id that day: ¥639.

item id domain search-page price detail price (before fix)
1009201607950 detail.tmall.com (百亿补贴) ¥639 ¥9999
837347358130 detail.tmall.com (百亿补贴) ¥439 ¥9999
1046289235959 detail.tmall.com (百亿补贴) ¥1609 ¥3999 (not 9999 — a literal-value check misses this)
1039885607033 detail.tmall.com (百亿补贴) ¥2388.89 ¥4499 (not 9999 either)
900757934496 detail.tmall.com (百亿补贴) ¥149 ¥9999
800417757444 detail.tmall.com (ordinary) ¥253.73 ¥7 (wrong, unrelated small amount picked up)
1054085314971 detail.tmall.com ¥639 ¥639 (this one happened to work)

Root cause

clis/taobao/detail.js's func extracts the price with a blind regex scan
of document.body.innerText:

const pricePattern = /[¥]\s*(\d+(?:\.\d{1,2})?)/g;
const prices = [];
let m;
while ((m = pricePattern.exec(text)) && prices.length < 3) {
  const p = parseFloat(m[1]);
  if (p > 0.1 && p < 100000) prices.push(p);
}
if (prices.length > 0) {
  results.push({ field: '价格', value: '¥' + Math.min(...prices) });
}

Two independent problems:

  1. Wrong signal source, and the "obviously fake" value isn't reliably
    detectable by its literal number.
    For 百亿补贴-channel items, Taobao's
    server does not lazily inject the real price via a follow-up XHR — the
    entire SSR payload already contains only the anchor/decoy value.
    Confirmed via
    window.__ICE_APP_CONTEXT__.loaderData.home.data.res.componentsVO.priceVO:
    direct navigation to a gated item yields e.g.
    {"price":{"priceText":"3999", ...}} with no extraPrice key at
    all
    . The anchor number varies by item/campaign (9999 / 3999 /
    4499 all observed) — it is whatever the seller/campaign configured as
    the pre-subsidy list price, not a single well-known sentinel. The
    structural signal (extraPrice present vs. absent) is reliable; the
    numeric value is not.
    The real price only appears once the request
    carries the session's umpChannel / fpChannelSig query params, which
    are attached to the <a href> Taobao's own search-results page renders
    for the item (see Fix below). No amount of selector tuning on the
    current page recovers the real price — the page genuinely does not
    contain it without the channel-authenticated navigation.

    Caveat that makes this harder than "check for extraPrice": a plain
    item that simply has no coupon/subsidy tier at all has the exact same
    JSON shape (priceVO = { price: {...}, isNewStyle }, no extraPrice)
    as a gated decoy render, and its price.priceText is the real
    price (confirmed on id 883464195875, a small C2C listing, real ¥9.9,
    no extraPrice, ever). So extraPrice absence alone cannot be treated
    as "this is a decoy" — it can only be treated as "this page needs
    verification". See Fix step 2 for how the patch resolves that
    ambiguity instead of just value-matching harder.

  2. Math.min over an unstructured 3-match scan is fragile even for
    ordinary items.
    For id 800417757444, document.body.innerText
    contains ¥253.73 (the real price), ¥449 (pre-coupon anchor), and an
    unrelated ¥7 elsewhere on the page (observed to be some other on-page
    amount, not further identified) — Math.min picks ¥7. This is a latent
    bug independent of the decoy issue above.

Suggested fix

  1. Prefer the structured price state over regex text-scraping.
    window.__ICE_APP_CONTEXT__.loaderData.home.data.res.componentsVO.priceVO
    exposes extraPrice.priceText (final after-coupon/after-subsidy price,
    e.g. "券后"/"补贴后") and price.priceText (pre-discount anchor / decoy
    fallback). Use extraPrice.priceText ?? price.priceText as the primary
    source; fall back to the existing regex scan only when
    __ICE_APP_CONTEXT__ isn't present at all (older/non-tmall templates).

  2. Treat extraPrice absence as "unverified", not "decoy" — verify by
    re-fetching through the item's own search-result link, then trust
    whatever that authenticated page shows, unconditionally.
    Concretely:

    • Search s.taobao.com/search?q=<title prefix> using the item's title
      (already extracted from state/DOM). A full raw title can return zero
      hits, so try a few progressively shorter prefixes (e.g. 20 / 12 / 8
      chars) until one surfaces a matching anchor.
    • Match the candidate anchor by strict id query-param equality,
      not substring (href*="id=123" also matches an unrelated item whose
      id is e.g. 1234567 — 123 is a numeric prefix of it).
    • Navigate to that exact href (it already carries
      umpChannel=bybtqdyh&fpChannelSig=... etc. — this is reused verbatim
      from the page's own DOM, not computed or forged) and re-extract price
      from state.
    • Trust the post-recovery fetch outright, whether or not extraPrice
      appears on it.
      Some gated items resolve the corrected price
      straight into price.priceText with no separate extraPrice tier at
      all (confirmed on id 900757934496: decoy price.priceText "9999"
      → real "149" after recovery, extraPrice never appears on that item
      even once unlocked). A fix that only accepts the recovered value when
      extraPrice shows up, or only when the value is unchanged from
      before, will still drop this case. If the item was never actually
      gated (the 883464195875 case), the recovered fetch simply confirms
      the same value the original fetch had — harmless, just an extra
      round trip.
    • This is a legitimate reuse of a URL the site serves to any logged-in
      session that reaches the item via search; it does not bypass any auth
      or anti-bot check, it just follows the same path a real user's click
      would.
  3. Never surface an anchor/decoy value as if it were a real price, and
    never rely on a literal-value denylist to detect one.
    If the search
    recovery link can't even be found (item not indexed under any tried
    title prefix), omit the 价格 row entirely rather than falling back
    to the original, unverified fetch. At that point there is no positive
    way to tell "no coupon, price.priceText is real" apart from "gated,
    price.priceText is a decoy we couldn't verify" — falling back to the
    original value would silently risk leaking a decoy in exactly the cases
    this whole patch exists to catch. A missed price (exit as if scraping
    failed for this item) is always preferable to a wrong one.

I have a working drop-in replacement for clis/taobao/detail.js implementing all
three points above, verified against the 9-item regression fixture described in the
Verification section (opencli validate taobao/detail passes, exit code 0). Happy to
open a PR with it if that's welcome.

Verification

Do not read this section as "N items passed, therefore fixed." The
patch went through two rounds: an initial literal-value (=== '9999')
version passed a 7-item sample cleanly, then adversarial re-testing against
a different, independently-chosen sample immediately found two items
(1046289235959, 1039885607033) whose decoy was 3999/4499 and which
silently passed the literal-value check through as real data — plus a
third regression (883464195875, a genuine no-coupon item) where an
overly aggressive "extraPrice-absence = decoy" rule wrongly suppressed a
real price. The current patch (structural detection + unconditional trust
of the post-recovery fetch, described above) was specifically shaped to
survive both failure modes, not just to pass the original sample.

Machine-checked against well over a dozen items across three rounds of
adversarial re-testing, including at least 8 confirmed-gated 百亿补贴 items
spanning three different decoy anchor values (9999 / 3999 / 4499) and
several non-subsidy tmall + taobao.com C2C items (including ones with no
extraPrice tier at all, to guard the no-false-positive direction). All
resolve to the search-page reference price (or a small, live, same-day
price-drift delta — 百亿补贴 prices appear to be per-request A/B-test
bucketed, given the aplus_abtest query param on their channel links)
with zero decoy values of any kind leaked into the output. The 9-item
permanent regression fixture lives in scripts/verify_taobao_opencli.py
and currently passes with exit code 0. opencli validate taobao/detail
passes.

Fixed: recovery-failure fallback no longer leaks unverified prices

An earlier draft of this report flagged the following as an open gap: if
the recovery hop can't find the item's own search-result link under any
tried title prefix, the patch had no positive way to distinguish "no
coupon, price.priceText is real" from "gated, price.priceText is a
decoy we couldn't verify" — and at the time it fell back to the original,
unverified fetch, which meant a sufficiently obscure gated item could
still leak a decoy. This has been closed: when recovery can't locate a
channel href under any prefix, the patch now omits the 价格 row entirely
instead of falling back to the unverified value.

We got a live, unplanned confirmation of exactly this path: id
883464195875 (a small individual-seller phone-case listing, genuinely
priced at ¥9.9, not gated) was reliably recoverable via title search when
this patch was first written, then a few hours later dropped out of
s.taobao.com search results entirely (ordinary C2C listing churn — small
sellers' items move in and out of top search ranking with stock/sales, not
a site-defense behavior). All three title-prefix searches now return zero
matches for it. Its underlying page still genuinely shows price.priceText: "9.9" (still the real, non-decoy value — it was just never gated in the
first place), so the old "fall back to original" behavior would have
happened to still emit the correct ¥9.9 by coincidence here. The current
adapter instead correctly omits the 价格 row for this id — the right
call in general, since the adapter can no longer prove the value wasn't
a decoy once recovery fails, even though in this specific instance the
un-omitted value would have been correct.

Residual tradeoff (accepted, not a bug): this trades a small amount of
recall — a genuinely correctly-priced item can now surface with no price
at all if it happens to be temporarily unfindable via search — for the
much stronger guarantee that a decoy value can never leak through the
"recovery failed" path. Given the cost of a false price (data poisoning
downstream: corrupted price history, spurious price-drop alerts) versus
the cost of a missed price (one skipped scrape, retryable next cycle),
this is the correct tradeoff for a price-tracking use case. Machine
verification: scripts/verify_taobao_opencli.py's fixture was
swapped from 883464195875 to a currently-stable equivalent
(1001997074113, also no-extraPrice, also taobao.com C2C) specifically
because the original anchor's search-findability is no longer guaranteed
to be stable day-to-day — its unpredictability now demonstrates the omit
path rather than the "boundary preserved" path, so it stopped being a
useful fixed regression anchor for that specific behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions