Metadata-Version: 2.5
Name: amiami-api
Version: 0.1.0
Summary: Unofficial AmiAmi API — Python library and local MCP server for Japan's largest hobby retailer
Project-URL: Homepage, https://github.com/ssskay/amiami-api
Project-URL: Issues, https://github.com/ssskay/amiami-api/issues
Author-email: Sara Kay <sara@sarakay.me>
License: MIT
License-File: LICENSE
Keywords: amiami,anime,figures,hobby,japan,mcp,scraper
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
Requires-Python: >=3.10
Requires-Dist: curl-cffi>=0.7
Requires-Dist: mcp>=1.2
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# amiami-api

An unofficial [AmiAmi](https://www.amiami.com) API: a Python library plus a local
**MCP server**, so agents can search Japan's largest online figure and hobby
retailer, read live prices and stock, and join it to MyFigureCollection by
barcode.

AmiAmi's storefront runs on a JSON API that has never been documented or
announced. This package speaks it directly.

## New here? The short version

AmiAmi is where a large share of the world's anime figures are actually bought —
mostly as preorders, months before release. There is no official API, the
existing wrappers are unmaintained toys, and the whole thing sits behind
Cloudflare.

This project gives you a working client on your own computer. An AI assistant
like Claude can then answer things like "is this Nendoroid still up for
preorder, and what does it cost?" — and, because AmiAmi publishes JAN barcodes,
tie that answer to the same figure on MyFigureCollection.

```mermaid
sequenceDiagram
    participant You
    participant Claude
    participant mfc as mfc-api
    participant ami as amiami-api
    You->>Claude: What's this figure going for?
    Claude->>mfc: search_by_barcode(4570232589346)
    mfc-->>Claude: MFC item 287 + metadata
    Claude->>ami: find_by_jan(4570232589346)
    ami-->>Claude: FIGURE-205113 · Pre-order · ¥9,900
    Claude-->>You: Still up for preorder at AmiAmi, ¥9,900 (¥11,000 taxed)
```

**What is MCP?** The Model Context Protocol is a standard plug for giving AI
assistants new abilities. An MCP server is a small program on your machine that
an assistant is allowed to call. This one gives your assistant three AmiAmi
abilities.

## The one thing to understand: availability has seven states

AmiAmi sells the same catalogue three ways — stock on hand, open preorders, and
second-hand — and a boolean is wrong for all of them in different ways.

| `availability` | What it means |
|---|---|
| `in_stock` | On the shelf, ships now. |
| `preorder` | Not made yet; orderable, with a release month. |
| `provisional_preorder` | AmiAmi will take the order, but release date and sometimes price aren't fixed. |
| `backorder` | Orderable on back-order. Real, but AmiAmi seems to have wound these down — the filter returned 0 results on every keyword tried. |
| `order_closed` | The order window shut. |
| `unavailable` | Released and no longer orderable. Modelled, but **not once observed in the wild** — see below. |
| `unknown` | **Not checked, or unreadable.** Never treat this as available. |

`unavailable` is defined because the state logically exists, but every product
that `/item` knows about was also findable in search with a real stock flag, and
every code that isn't returns "Invalid Request" (a `AmiAmiNotFoundError`, not an
availability). AmiAmi appears to delist rather than mark-as-gone. If you see
`unavailable` in the wild, that is new behaviour worth reporting.

`condition` (`new` / `pre_owned`) is a **separate axis**. A pre-owned item can
be in stock and a new one can be gone. Collapsing them is how you end up
offering someone a used figure when they asked for a sealed one.

### Three fields that lie, and two endpoints that disagree

Verified live on 2026-07-31, and the reason this package doesn't just forward
AmiAmi's JSON:

- **`soldout_flg` is `1` on open preorders you can add to a cart right now.** It
  seems to mean "no physical stock on hand", which is trivially true of anything
  unreleased. Ignored here.
- **`stock` is `1` on both an orderable preorder and a long-gone item.** It is
  not a quantity and not a buyability boolean. Also ignored.
- **`instock_flg` from `/item` is `0` for every released product**, in stock or
  not — while the *same product* reports `1` in search. Checked within the same
  minute for `FIGURE-191979`:

  | Endpoint | `instock_flg` |
  |---|---|
  | `/items` (search) | **1** |
  | `/item?gcode=FIGURE-191979` | **0** |

So `item()` makes a second request against search and takes availability from
there. Without it the answer is `unknown` with `availability_confirmed: false` —
never a confident `unavailable`. `confirm_stock=False` opts out and keeps the
honest `unknown`.

A client that trusts any of these three gets the answer backwards on most of the
catalogue.

### Search never returns unavailable items

All 158 sampled result rows had `order_closed_flg: 0`. Sold-out products are
absent from search entirely — so **an empty result means "nothing buyable
matched", not "no such product"**. To see something retired, look it up by
`gcode`.

And when you do, note that AmiAmi returns the *same* error for a retired product
and a code that never existed (`RSuccess: false`, `"Invalid Request"`). It does
not distinguish them, so neither does this package.

## Architecture

`api.amiami.com` blocks on **TLS fingerprint**. `httpx` gets a 403 challenge
page no matter what headers it sends. This package uses
[`curl_cffi`](https://github.com/lexiforest/curl_cffi) with
`impersonate="chrome"` — the same trick that makes
[`myfigurecollection-api`](https://github.com/ssskay/myfigurecollection-api)
work. **That choice is load-bearing.**

The profile has to be *current*, not merely present:

| Profile | Result |
|---|---|
| `httpx` (no impersonation) | 403 Cloudflare |
| `curl_cffi`, no profile | 403 Cloudflare |
| `chrome110` | 403 Cloudflare |
| `chrome` (latest), `safari17_0`, `firefox133` | 200 |

If this starts raising `AmiAmiBlockedError`, try a newer profile before assuming
the API is gone.

**Cloudflare also refuses a perfectly valid request now and then** — roughly 1 in
6 cold requests, measured over six fresh sessions on 2026-07-31. It's transient,
so the transport retries before giving up; `AmiAmiBlockedError` only surfaces
when *every* attempt is challenged. Don't treat a single challenge in your own
logs as evidence of anything.

Two things folklore says you need and you don't: **no session warm-up** (both
endpoints answer cold — hitting `amiami.com` first is pointless, since the
storefront is *more* walled than the API and 403s even under impersonation), and
**no cookies**.

One thing you do need: the `X-User-Key: amiami_dev` header. It isn't a secret or
per-user — it's a constant the public site ships. Curiously, omitting it gets you
a Cloudflare challenge rather than an API error, while sending a *wrong* one gets
a clean HTTP 400 (`ErrorCode 10210`). This package raises different exceptions
for the two, because they need different fixes.

HTTP still sits behind an ABC, same seam as `mandarake-api`:

```python
class Transport(ABC):
    @abstractmethod
    def get(self, url: str, **kw) -> Response: ...
```

Parsers take response bodies as strings and never touch the network.

## Install

Needs **Python 3.10+**. The package is on PyPI:

```bash
pip install amiami-api
```

On a Mac, `python3` is often Xcode's 3.9, which will not run this code, so be
explicit about the interpreter (e.g. `pip3.12`). Working from a clone instead,
use `pip3.12 install -e .`.

Then check it landed:

```bash
amiami-api search "hatsune miku"
```

## Use it as an MCP server

`amiami-api` with no arguments starts the MCP server on stdio. Add this to your MCP
config — `~/Library/Application Support/Claude/claude_desktop_config.json` for Claude
Desktop, or `.mcp.json` in your project for Claude Code:

```json
{
  "mcpServers": {
    "amiami": {
      "command": "uvx",
      "args": ["amiami-api"]
    }
  }
}
```

This needs [`uv`](https://docs.astral.sh/uv/) installed; `uvx` fetches the package
from PyPI on first run, so there is nothing else to set up.

No `uv`? Point `command` at the installed script by its **absolute path**, e.g.
`/Library/Frameworks/Python.framework/Versions/3.12/bin/amiami-api`. The path must be
absolute because MCP clients launch servers with a minimal `PATH` that does not
include your shell's Python bin directory — a bare `"command": "amiami-api"` will fail
even though it works in your terminal.

### Tools

| Tool | What it returns |
|---|---|
| `search(keyword, lang, page, per_page, preorder_only, in_stock_only, preowned_only)` | Listings with price, availability, condition and JAN |
| `item(gcode, lang)` | Full record: both names, prices, JAN, spec, sculptors, series/character tags |
| `find_by_jan(jan, lang)` | Barcode lookup — the join key to MyFigureCollection |

### Configuration

| Environment variable | Default | Meaning |
|---|---|---|
| `AMIAMI_API_RATE_LIMIT` | `1.0` | Seconds between requests |
| `AMIAMI_API_CACHE_TTL` | `3600` | Cache lifetime in seconds; `0` disables |
| `AMIAMI_API_CACHE_DIR` | `~/.cache/amiami-api` | Where cached responses live |
| `AMIAMI_API_IMPERSONATE` | `chrome` | `curl_cffi` TLS profile |
| `AMIAMI_API_USER_KEY` | `amiami_dev` | The `X-User-Key` header |
| `AMIAMI_API_LANG` | `eng` | Default store (`eng` or `jpn`) |
| `AMIAMI_API_LOG_LEVEL` | `WARNING` | Server log level (logs go to stderr) |

Every request is logged at `DEBUG` with its URL, status and body size.

## Use it as a library

```python
from amiami_api import AmiAmiClient, Availability, Condition

with AmiAmiClient() as amiami:
    results = amiami.search("hatsune miku", preorder_only=True)
    print(results.pagination.total_results)

    for listing in results.items:
        print(listing.availability.value, listing.price_jpy, listing.name)

    # Barcode in, live price out — the join to MyFigureCollection
    for match in amiami.find_by_jan("4570232589346"):
        print(match.gcode, match.price_jpy, match.availability.value)

    item = amiami.item("FIGURE-205113")
    print(item.name, "/", item.name_ja)
    print(item.availability is Availability.PREORDER, item.condition is Condition.NEW)
    print(item.sculptors, item.release_date)
```

### `lang` picks the store, and the store decides your keyword's script

`eng` is the international store, `jpn` the Japanese one. They hold different
products — but the trap is the *keyword*, not the catalogue. Each store indexes
names in its own writing system, so a keyword in the wrong script silently
under-matches. Measured 2026-07-31:

| keyword | `lang=eng` | `lang=jpn` |
|---|---|---|
| `nendoroid` | 7,527 | **4** |
| `ねんどろいど` | 1,236 | **12,670** |
| `miku` | 11,282 | 459 |
| `初音ミク` | 5,476 | 11,037 |

Searching `nendoroid` against the Japanese store returns 4 results and looks
like a working query. Match the script to the store.

## Use it from the shell

```bash
amiami-api search "hatsune miku" --preorder
```

```bash
amiami-api item FIGURE-205113
```

```bash
amiami-api jan 4570232589346
```

```bash
amiami-api search miku --preowned --lang jpn
```

Everything prints JSON on stdout; logs go to stderr. Add `-v` for request logs.

## A note on `find_by_jan`

AmiAmi has no barcode endpoint. `find_by_jan` searches the barcode as a keyword
and then keeps only exact `jancode` matches.

That filter is insurance, not a fix for observed misbehaviour: barcode search
was exact in every case tested on 2026-07-31 (five JANs, zero non-matching
rows). It stays because keyword search is not *documented* to be exact, and
silently pairing a barcode with a neighbouring product is the kind of error you
only notice after buying the wrong figure.

It returns a **list**, because one barcode can match more than one listing —
`4570157184640` returns two.

## Being a good citizen

- Rate-limited to **1 request/second** by default, process-wide.
- Responses are cached on disk for an hour.
- Read-only. No cart, order, account or write operations, and none are planned.

This is an undocumented API belonging to a real shop. Don't hammer it, and don't
build anything that competes with them on their own data.

## Tests

```bash
python3.12 -m pytest
```

The default run is entirely offline — parser tests read saved API responses
captured on 2026-07-31, and the transport is driven with a fake session. Tests
that hit the live API are marked `live`:

```bash
python3.12 -m pytest -m live
```

Those are the canary. They fail loudly and specifically if the TLS profile goes
stale, if `X-User-Key` stops working, if the JSON envelope changes shape, or if
the misleading flags start meaning what they say.

## Disclaimer

Unofficial. Not affiliated with, endorsed by, or supported by AmiAmi or Oh-ami
Inc. It calls an undocumented, unversioned API and will break without notice.
Product data and images belong to AmiAmi.

MIT licensed — see [LICENSE](LICENSE).

Built by [Sara Kay](https://sarakay.me).

mcp-name: io.github.ssskay/amiami-api
