@chrischall/tripadvisor-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for the TripAdvisor Terra API — location search, details, photos, and reviews",
10
- "version": "0.2.1"
10
+ "version": "0.3.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "TripAdvisor",
16
16
  "source": "./",
17
17
  "description": "TripAdvisor travel data via the Terra API — search hotels, restaurants, and attractions, with details, photos, and reviews",
18
- "version": "0.2.1",
18
+ "version": "0.3.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tripadvisor-mcp",
3
3
  "displayName": "TripAdvisor",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "MCP server for the TripAdvisor Terra API — location search, details, photos, and reviews",
6
6
  "author": {
7
7
  "name": "Chris Hall",
@@ -19,6 +19,6 @@
19
19
  "reviews",
20
20
  "mcp"
21
21
  ],
22
- "skills": "./SKILL.md",
22
+ "skills": "./skills/",
23
23
  "mcp": "./.mcp.json"
24
24
  }
package/dist/bundle.js CHANGED
@@ -34939,7 +34939,7 @@ var pageSchema = {
34939
34939
  };
34940
34940
 
34941
34941
  // src/version.ts
34942
- var VERSION = "0.2.1";
34942
+ var VERSION = "0.3.0";
34943
34943
 
34944
34944
  // src/client.ts
34945
34945
  import { dirname, join } from "node:path";
@@ -35273,6 +35273,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
35273
35273
  "capture_request_header",
35274
35274
  "capture_redirect",
35275
35275
  "read_indexed_db",
35276
+ "read_dom",
35276
35277
  "download"
35277
35278
  ]);
35278
35279
 
@@ -35574,6 +35575,44 @@ function assertIndexedDbScopesArray(value, label) {
35574
35575
  }
35575
35576
  }
35576
35577
  }
35578
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35579
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35580
+ function assertDomSelectorsArray(value, label) {
35581
+ if (!Array.isArray(value)) {
35582
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35583
+ }
35584
+ const seen = /* @__PURE__ */ new Set();
35585
+ for (let i = 0; i < value.length; i++) {
35586
+ const entry = value[i];
35587
+ assertObject(entry, `${label}[${i}]`);
35588
+ if (entry.name === void 0) {
35589
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35590
+ }
35591
+ if (entry.selector === void 0) {
35592
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35593
+ }
35594
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35595
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35596
+ }
35597
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35598
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35599
+ }
35600
+ if (entry.attribute !== void 0) {
35601
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35602
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35603
+ }
35604
+ }
35605
+ if (seen.has(entry.name)) {
35606
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35607
+ }
35608
+ seen.add(entry.name);
35609
+ for (const k of Object.keys(entry)) {
35610
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35611
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35612
+ }
35613
+ }
35614
+ }
35615
+ }
35577
35616
  function validateFrame(raw) {
35578
35617
  assertObject(raw, "frame");
35579
35618
  const t = raw.type;
@@ -35649,6 +35688,9 @@ function validateHello(raw) {
35649
35688
  if (raw.sessionStoragePointers !== void 0) {
35650
35689
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35651
35690
  }
35691
+ if (raw.domSelectors !== void 0) {
35692
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35693
+ }
35652
35694
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35653
35695
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35654
35696
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35883,6 +35925,21 @@ function validateInnerRequest(raw) {
35883
35925
  }
35884
35926
  return raw;
35885
35927
  }
35928
+ if (raw.op === "read_dom") {
35929
+ assertObject(raw.init, "inner.init");
35930
+ if (raw.init.origin === void 0)
35931
+ throw new ProtocolError("inner.init.origin: missing");
35932
+ if (raw.init.names === void 0)
35933
+ throw new ProtocolError("inner.init.names: missing");
35934
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35935
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35936
+ for (const k of Object.keys(raw.init)) {
35937
+ if (k !== "origin" && k !== "names") {
35938
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35939
+ }
35940
+ }
35941
+ return raw;
35942
+ }
35886
35943
  if (raw.op === "download") {
35887
35944
  assertObject(raw.init, "inner.init");
35888
35945
  if (raw.init.url === void 0) {
@@ -35908,7 +35965,7 @@ function validateInnerRequest(raw) {
35908
35965
  }
35909
35966
  return raw;
35910
35967
  }
35911
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35968
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35912
35969
  }
35913
35970
  function assertNonEmptyKeyArray(value, label) {
35914
35971
  if (!Array.isArray(value)) {
@@ -35993,6 +36050,13 @@ function validateInnerResponse(raw) {
35993
36050
  assertObject(raw.values, "inner.values");
35994
36051
  return raw;
35995
36052
  }
36053
+ if (op === "read_dom") {
36054
+ if (raw.values === void 0) {
36055
+ throw new ProtocolError("inner.values: missing on read_dom response");
36056
+ }
36057
+ assertStringMap(raw.values, "inner.values");
36058
+ return raw;
36059
+ }
35996
36060
  if (op === "download") {
35997
36061
  assertObject(raw.value, "inner.value");
35998
36062
  assertString(raw.value.path, "inner.value.path");
@@ -36332,6 +36396,13 @@ async function buildServerHello(opts) {
36332
36396
  jsonPointer: d.jsonPointer
36333
36397
  }));
36334
36398
  }
36399
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36400
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36401
+ name: d.name,
36402
+ selector: d.selector,
36403
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36404
+ }));
36405
+ }
36335
36406
  return hello;
36336
36407
  }
36337
36408
 
@@ -36421,7 +36492,8 @@ async function startHost(opts) {
36421
36492
  captureHeaders: opts.ownCaptureHeaders,
36422
36493
  indexedDbScopes: opts.ownIndexedDbScopes,
36423
36494
  localStoragePointers: opts.ownLocalStoragePointers,
36424
- sessionStoragePointers: opts.ownSessionStoragePointers
36495
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36496
+ domSelectors: opts.ownDomSelectors
36425
36497
  });
36426
36498
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36427
36499
  let extensionWs = null;
@@ -36656,6 +36728,7 @@ async function startPeer(opts) {
36656
36728
  sessionStorageKeys: opts.sessionStorageKeys,
36657
36729
  captureHeaders: opts.captureHeaders,
36658
36730
  indexedDbScopes: opts.indexedDbScopes,
36731
+ domSelectors: opts.domSelectors,
36659
36732
  localStoragePointers: opts.localStoragePointers,
36660
36733
  sessionStoragePointers: opts.sessionStoragePointers
36661
36734
  });
@@ -37063,6 +37136,11 @@ var FetchproxyServer = class {
37063
37136
  key: d.key,
37064
37137
  jsonPointer: d.jsonPointer
37065
37138
  })),
37139
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
37140
+ name: d.name,
37141
+ selector: d.selector,
37142
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
37143
+ })),
37066
37144
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
37067
37145
  // adapter was about to set these to the same numbers anyway; the
37068
37146
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -37183,6 +37261,7 @@ var FetchproxyServer = class {
37183
37261
  ownIndexedDbScopes: this.opts.indexedDbScopes,
37184
37262
  ownLocalStoragePointers: this.opts.localStoragePointers,
37185
37263
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
37264
+ ownDomSelectors: this.opts.domSelectors,
37186
37265
  onPairCode: this.opts.onPairCode
37187
37266
  });
37188
37267
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -37210,7 +37289,8 @@ var FetchproxyServer = class {
37210
37289
  captureHeaders: this.opts.captureHeaders,
37211
37290
  indexedDbScopes: this.opts.indexedDbScopes,
37212
37291
  localStoragePointers: this.opts.localStoragePointers,
37213
- sessionStoragePointers: this.opts.sessionStoragePointers
37292
+ sessionStoragePointers: this.opts.sessionStoragePointers,
37293
+ domSelectors: this.opts.domSelectors
37214
37294
  });
37215
37295
  this.peerHandle.onInner((inner) => this.onInner(inner));
37216
37296
  this.peerHandle.onRenegotiate(() => {
@@ -38185,6 +38265,46 @@ var FetchproxyServer = class {
38185
38265
  await this.sendInnerFrame(inner);
38186
38266
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
38187
38267
  }
38268
+ /**
38269
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
38270
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
38271
+ * match a declared `domSelectors` entry. The extension reads each
38272
+ * declared selector from the matched tab's DOM (isolated-world
38273
+ * `querySelector`, value or attribute) — no page-JS execution.
38274
+ *
38275
+ * Returns a `Record<string, string>` of `name → value`, with names
38276
+ * whose element (or attribute) was absent omitted. Throws
38277
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
38278
+ * developer mistakes (undeclared capability, undeclared name).
38279
+ */
38280
+ async readDom(opts) {
38281
+ if (!this.opts.capabilities.includes("read_dom")) {
38282
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
38283
+ }
38284
+ await this.ensureConnected();
38285
+ this.throwIfPendingPair();
38286
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
38287
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
38288
+ }
38289
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
38290
+ if (opts.subdomain !== void 0)
38291
+ assertSubdomainLabel(opts.subdomain);
38292
+ const baseDomain = this.resolveBaseDomain(opts.domain);
38293
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
38294
+ const origin = `https://${host}`;
38295
+ const id = this.nextRequestId++;
38296
+ const inner = {
38297
+ type: "request",
38298
+ id,
38299
+ op: "read_dom",
38300
+ init: { origin, names: [...opts.names] }
38301
+ };
38302
+ const pending = new Promise((resolve, reject) => {
38303
+ this.pendingStorage.set(id, { resolve, reject });
38304
+ });
38305
+ await this.sendInnerFrame(inner);
38306
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
38307
+ }
38188
38308
  assertScopeSubset(requested, declared, label) {
38189
38309
  const undeclared = undeclaredKeys(requested, declared);
38190
38310
  if (undeclared.length > 0) {
@@ -38256,7 +38376,7 @@ var FetchproxyServer = class {
38256
38376
  if (storageCb) {
38257
38377
  this.pendingStorage.delete(inner.id);
38258
38378
  if (inner.ok) {
38259
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38379
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
38260
38380
  storageCb.resolve({ ...inner.values });
38261
38381
  } else {
38262
38382
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '0.2.1'; // x-release-please-version
2
+ export const VERSION = '0.3.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/tripadvisor-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.chrischall/tripadvisor-mcp",
5
5
  "description": "TripAdvisor Terra API MCP server for Claude — search locations, details, photos, and reviews. Developed and maintained by AI (Claude Code).",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -31,7 +31,7 @@
31
31
  "files": [
32
32
  "dist",
33
33
  ".claude-plugin",
34
- "SKILL.md",
34
+ "skills",
35
35
  ".mcp.json",
36
36
  "server.json"
37
37
  ],
@@ -44,7 +44,7 @@
44
44
  "test:coverage": "vitest run --coverage"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.12.0",
47
+ "@chrischall/mcp-utils": "^0.13.0",
48
48
  "@fetchproxy/server": "^1.3.4",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "dotenv": "^17.4.0",
@@ -54,7 +54,7 @@
54
54
  "@types/node": "^26.0.0",
55
55
  "@vitest/coverage-v8": "^4.1.2",
56
56
  "esbuild": "^0.28.0",
57
- "typescript": "^6.0.2",
57
+ "typescript": "^7.0.2",
58
58
  "vitest": "^4.1.2"
59
59
  },
60
60
  "allowScripts": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/tripadvisor-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.1",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/tripadvisor-mcp",
14
- "version": "0.2.1",
14
+ "version": "0.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: tripadvisor-api
3
+ description: >-
4
+ Query TripAdvisor location data (search, nearby, details, photos, reviews)
5
+ straight from a shell with curl against the Terra REST API
6
+ (terra.tripadvisor.com), instead of running the tripadvisor-mcp server —
7
+ plus a no-API-key fallback that reads a location's public page through the
8
+ fpx browser bridge. Use when you want TripAdvisor data without the MCP, in
9
+ a script, or on a machine where the MCP isn't installed. Triggers on "check
10
+ TripAdvisor", "TripAdvisor location/restaurant/hotel/attraction search,
11
+ details, photos, reviews", or any TripAdvisor data request that should hit
12
+ the API directly.
13
+ ---
14
+
15
+ # TripAdvisor Terra API via curl (no MCP)
16
+
17
+ TripAdvisor's **Terra** API (`terra.tripadvisor.com/api`) is a plain
18
+ API-key REST API reachable directly from a server or shell — no browser
19
+ bridge needed. This skill shells out to `curl` with the key in an
20
+ `X-API-Key` header, exactly as `tripadvisor-mcp`'s `src/client.ts` does.
21
+ Terra is the current API (the legacy Content API sunsets 2026-08-31); it
22
+ has **no write endpoints** — everything here is a read.
23
+
24
+ A second, smaller tier at the bottom covers the one thing Terra can't do
25
+ without a key: reading a location's core details from the public
26
+ consumer page via the `fpx` browser bridge.
27
+
28
+ ## One-time setup: get a Terra key
29
+
30
+ ```sh
31
+ # Prefer the env var tripadvisor-mcp itself reads (check its .env first):
32
+ grep -h TRIPADVISOR_API_KEY ~/git/tripadvisor-mcp/.env 2>/dev/null
33
+ export TRIPADVISOR_API_KEY='...'
34
+ ```
35
+
36
+ If you don't have one: create a free **Discover**-tier key at
37
+ https://www.tripadvisor.com/developers (pay-as-you-go, 10 QPS / 10,000
38
+ calls/day). A **legacy** Content API key does NOT work here (and vice
39
+ versa) — a mismatched key gets a `403`.
40
+
41
+ ## Core call pattern
42
+
43
+ ```sh
44
+ BASE=https://terra.tripadvisor.com/api
45
+
46
+ curl -sS "$BASE/locations/search?query=Golden%20Gate%20Bridge" \
47
+ -H "X-API-Key: $TRIPADVISOR_API_KEY" -H 'accept: application/json' \
48
+ | jq '.data[].location | {id, name: (.names[] | select(.primary) | .value)}'
49
+ ```
50
+
51
+ Every call needs just the two headers above — no session/cookie, no
52
+ mutual TLS. Ready-to-run recipes for all 6 endpoints are in
53
+ `references/terra-endpoints.md`.
54
+
55
+ ## The one rule: resolve a location id first
56
+
57
+ Details/photos/reviews are keyed by numeric `location_id` — get one from
58
+ `ta_search_locations`'s equivalent, `GET /locations/search`, before
59
+ calling the id-scoped endpoints:
60
+
61
+ ```sh
62
+ curl -sS "$BASE/locations/search?query=Golden+Gate+Bridge" \
63
+ -H "X-API-Key: $TRIPADVISOR_API_KEY" | jq -r '.data[].location.id'
64
+ # 104675
65
+ ```
66
+
67
+ ## Response shapes (quick reference)
68
+
69
+ - List endpoints (`search`, `nearby`, `reviews`, `photos`) wrap results:
70
+ `{"data": [...], "pagination": {"page","size","total_pages","total_elements"}}`.
71
+ - `GET /locations` (batch) and `GET /locations/{id}` (details) do **not**
72
+ wrap in `pagination` — batch returns `{"data": [...]}`, details returns
73
+ the location object directly.
74
+ - A **location** object keys `names`/`descriptions`/`addresses` as
75
+ **arrays** tagged by language — the primary entry has `"primary": true`
76
+ (`jq '.names[] | select(.primary) | .value'`), not a flat `name` field.
77
+
78
+ Full field lists and all 6 curl+jq recipes: `references/terra-endpoints.md`.
79
+
80
+ ## Output / error contract
81
+
82
+ - A 2xx body is JSON; pipe to `jq`.
83
+ - `400` — validation error; body is `{type, title, status, detail,
84
+ field_errors: [{field, message}], trace_id}` — `detail`/`field_errors`
85
+ name the bad param.
86
+ - `401`/`403` — key missing, invalid, or the wrong API family (legacy key
87
+ on Terra, or vice versa).
88
+ - `404` — unknown id, or a typo'd path (`/locations/{id}` is **plural**;
89
+ the singular form 404s).
90
+ - `429` — QPS (10) or daily quota (10,000) exceeded on the Discover tier;
91
+ back off and retry (the response may carry `Retry-After`).
92
+
93
+ ## Fallback tier: no API key (fpx browser bridge)
94
+
95
+ TripAdvisor's consumer site (`www.tripadvisor.com`) is DataDome-walled, so
96
+ it can't be curled directly — but a location's public detail page is
97
+ plain server-rendered HTML with a clean `application/ld+json` block
98
+ (name, rating, review count, address, phone, coordinates), reachable with
99
+ **no API key** by routing the fetch through your own signed-in browser
100
+ tab via `fpx` (`@fetchproxy/cli`). Use this when you don't have (or don't
101
+ want to use) a Terra key, or `ta_get_location_details` is blocked.
102
+
103
+ ```sh
104
+ npm install -g @fetchproxy/cli # provides `fpx`
105
+ fpx profile add tripadvisor --domain tripadvisor.com # fetch capability only
106
+ fpx pair -p tripadvisor # prints a pair code → approve in Transporter
107
+ ```
108
+
109
+ Requires the **Transporter** extension with an open `www.tripadvisor.com`
110
+ tab. This covers attractions, hotels, and restaurants — it does **not**
111
+ return individual review text (only the aggregate rating/count). Fetch +
112
+ parse recipe: `references/web-fallback.md`.
113
+
114
+ ### fpx exit codes (fetch verbs)
115
+
116
+ - `0` — success.
117
+ - `2` — bridge unavailable: extension not connected / pairing pending →
118
+ `fpx pair -p tripadvisor`.
119
+ - `3` — bot wall: the tab hasn't cleared DataDome → open/refresh a
120
+ `www.tripadvisor.com` tab and retry.
121
+ - `4` — upstream non-2xx from TripAdvisor.
122
+
123
+ ## Notes
124
+
125
+ - Terra reads only — nothing here mutates TripAdvisor data.
126
+ - This project (`tripadvisor-mcp`) is developed and maintained by AI
127
+ (Claude Code).
@@ -0,0 +1,118 @@
1
+ # Terra API endpoints (curl + jq)
2
+
3
+ All paths are relative to `$BASE=https://terra.tripadvisor.com/api`. Every
4
+ call carries `-H "X-API-Key: $TRIPADVISOR_API_KEY" -H 'accept: application/json'`
5
+ (shorthand `"${H[@]}"` below). Shapes captured live with a Discover-plan key;
6
+ transcribed from `tripadvisor-mcp`'s `src/tools/search.ts` + `src/tools/location.ts`
7
+ (each section names its source tool). All 6 are `GET`, all read-only.
8
+
9
+ ```sh
10
+ BASE=https://terra.tripadvisor.com/api
11
+ H=(-H "X-API-Key: $TRIPADVISOR_API_KEY" -H 'accept: application/json')
12
+ ```
13
+
14
+ Categories are `RESTAURANT` | `ATTRACTION` | `HOTEL` (uppercase). `size` on
15
+ list endpoints defaults to 20 and is **capped at 20**.
16
+
17
+ ---
18
+
19
+ ## 1. Location search (`ta_search_locations` / `src/tools/search.ts`)
20
+
21
+ `GET /locations/search` — `query` (1–500 chars) required; optional
22
+ `category`, `search_type` (default `NAME`), `country_code` (alpha-2),
23
+ `geo_name`, `postal_code` (takes precedence over `geo_name`), `locale`
24
+ (repeated), `page`, `size`.
25
+
26
+ ```sh
27
+ curl -sS "${H[@]}" "$BASE/locations/search?query=Golden+Gate+Bridge&category=ATTRACTION" \
28
+ | jq '[.data[] | {id: .location.id, name: (.location.names[] | select(.primary) | .value),
29
+ geo: .location.geo, rating: .location.traveler_ratings.overall.rating}]'
30
+ ```
31
+
32
+ Response: `{"data": [{"location": <Location>, "matched_value": {"language","value"}}], "pagination": {...}}`.
33
+
34
+ ## 2. Nearby search (`ta_search_nearby` / `src/tools/search.ts`)
35
+
36
+ `GET /locations/nearby` — center is **exactly one** of:
37
+ `lat`+`lon`+`radius` (`unit=MI|KM`, default `MI`), `location_id`+`radius`,
38
+ or the box `sw_lat`,`sw_lon`,`ne_lat`,`ne_lon` (box mode ignores `radius`).
39
+ Plus optional `category`, `min_rating` (1.0–5.0), `include_photo` (bool),
40
+ `sort` (`distance`|`rating`), `page`, `size`, `locale`.
41
+
42
+ ```sh
43
+ # lat/lon + radius
44
+ curl -sS "${H[@]}" "$BASE/locations/nearby?lat=37.8199&lon=-122.4783&radius=5&unit=MI&category=RESTAURANT&sort=rating" \
45
+ | jq '[.data[] | {id: .location.id, name: (.location.names[] | select(.primary) | .value),
46
+ distance_mi: .distance_miles, bearing}]'
47
+
48
+ # location_id + radius (reference location as center)
49
+ curl -sS "${H[@]}" "$BASE/locations/nearby?location_id=104675&radius=2&unit=KM&category=HOTEL" | jq '.data'
50
+
51
+ # bounding box
52
+ curl -sS "${H[@]}" "$BASE/locations/nearby?sw_lat=37.70&sw_lon=-122.55&ne_lat=37.85&ne_lon=-122.35&category=ATTRACTION" \
53
+ | jq '.data'
54
+ ```
55
+
56
+ Response item: `{"location": <Location>, "bearing", "distance_miles", "distance_kilometers"}`.
57
+
58
+ ## 3. Batch multi-get (`ta_get_locations` / `src/tools/location.ts`)
59
+
60
+ `GET /locations` — repeated `id` param (1–50 ids), required; optional
61
+ `locale`. **No `pagination` wrapper.** Unknown/unlicensed ids are silently
62
+ omitted (not an error) — a malformed id, e.g. one exceeding int32, does 400.
63
+
64
+ ```sh
65
+ curl -sS "${H[@]}" "$BASE/locations?id=104675&id=93520&id=423942" \
66
+ | jq '[.data[] | {id, name: (.names[] | select(.primary) | .value)}]'
67
+ ```
68
+
69
+ Response: `{"data": [<Location>, ...]}` — cheaper than N single-id calls.
70
+
71
+ ## 4. Location details (`ta_get_location_details` / `src/tools/location.ts`)
72
+
73
+ `GET /locations/{id}` — path `id` (int), **plural** `/locations/{id}` (the
74
+ docs' llms.txt index shows the singular form; that 404s). Optional
75
+ `locale` (repeated).
76
+
77
+ ```sh
78
+ curl -sS "${H[@]}" "$BASE/locations/104675" | jq '{
79
+ id, geo, name: (.names[] | select(.primary) | .value),
80
+ rating: .traveler_ratings.overall, address: .addresses[0],
81
+ phone: .phone_numbers[0].value, url: .urls.tripadvisor.main
82
+ }'
83
+ ```
84
+
85
+ Response: the full `Location` object directly (not wrapped in `data`) —
86
+ `names`/`descriptions`/`addresses` are language-tagged arrays; the primary
87
+ entry has `"primary": true`.
88
+
89
+ ## 5. Location photos (`ta_get_location_photos` / `src/tools/location.ts`)
90
+
91
+ `GET /locations/{id}/photos` — optional `page`, `size` (max 20), `locale`.
92
+
93
+ ```sh
94
+ curl -sS "${H[@]}" "$BASE/locations/104675/photos?size=10" \
95
+ | jq '[.data[] | {id, url: .photo.original_size_url, w: .photo.original_width, h: .photo.original_height}]'
96
+ ```
97
+
98
+ Response: `{"data": [{"id","location_id","photo": {"key","original_size_url","original_height","original_width","media_type"}, "publish_ts", "source": {"name"}, "user"}], "pagination": {...}}`.
99
+
100
+ ## 6. Location reviews (`ta_get_location_reviews` / `src/tools/location.ts`)
101
+
102
+ `GET /locations/{id}/reviews` — optional `page`, `size` (max 20), `locale`.
103
+
104
+ ```sh
105
+ curl -sS "${H[@]}" "$BASE/locations/104675/reviews?size=10" | jq '.data'
106
+ ```
107
+
108
+ Response: `{"data": [...review objects...], "pagination": {...}}`.
109
+
110
+ ---
111
+
112
+ ## Error bodies
113
+
114
+ - `400` — `{"type","title","status","detail","field_errors": [{"field","message"}],"trace_id"}`.
115
+ - `401`/`403` — `{"Message": "..."}` (a legacy-vs-Terra key mismatch reads
116
+ as an AWS-gateway "explicit deny" message on the legacy endpoint).
117
+ - `404` — `{"message": "Not Found"}`.
118
+ - `429` — QPS (10) or daily quota (10,000) exceeded on Discover.
@@ -0,0 +1,91 @@
1
+ # Web fallback: location detail via fpx (no API key)
2
+
3
+ Covers `ta_web_get_location` (`tripadvisor-mcp`'s `src/tools/web.ts` +
4
+ `src/web/parse.ts`) — the one solid endpoint on the consumer site, reached
5
+ with **no Terra key** by routing through your own signed-in browser tab.
6
+ Shapes captured live 2026-07-04; re-verify if parsing drifts (see
7
+ `docs/TRIPADVISOR-WEB-API.md` in the repo for the full recon).
8
+
9
+ Setup (once): see `../SKILL.md`'s "Fallback tier" section
10
+ (`fpx profile add tripadvisor --domain tripadvisor.com` + `fpx pair -p tripadvisor`).
11
+
12
+ ## Canonical URL — works for every category
13
+
14
+ TripAdvisor canonicalizes on the `d<id>` segment; the `g<geo>` and the
15
+ `_Review` type prefix are corrected by a same-origin redirect that the
16
+ in-tab fetch follows. **One fixed URL form works whether the id is an
17
+ attraction, hotel, or restaurant** — no need to know the category up front:
18
+
19
+ ```
20
+ https://www.tripadvisor.com/Attraction_Review-g1-d<locationId>-Reviews-a-a.html
21
+ ```
22
+
23
+ ## Fetch + parse
24
+
25
+ ```sh
26
+ LOCATION_ID=104675
27
+ fpx get "https://www.tripadvisor.com/Attraction_Review-g1-d${LOCATION_ID}-Reviews-a-a.html" \
28
+ -p tripadvisor > /tmp/ta-location.html
29
+
30
+ # The page embeds 3 application/ld+json blocks; the business node is the
31
+ # one with BOTH `name` and `aggregateRating` (its @type varies by category:
32
+ # LocalBusiness=attraction, LodgingBusiness=hotel, FoodEstablishment=restaurant).
33
+ python3 - /tmp/ta-location.html <<'PY'
34
+ import re, json, sys
35
+ html = open(sys.argv[1]).read()
36
+ for m in re.findall(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S | re.I):
37
+ try:
38
+ obj = json.loads(m.strip())
39
+ except Exception:
40
+ continue
41
+ if isinstance(obj, dict) and 'name' in obj and 'aggregateRating' in obj:
42
+ print(json.dumps(obj))
43
+ break
44
+ PY
45
+ ```
46
+
47
+ Pipe that single-line JSON into `jq` for a slim projection matching what
48
+ `ta_web_get_location` returns:
49
+
50
+ ```sh
51
+ ... | jq '{
52
+ name, type: .["@type"], url,
53
+ rating: (.aggregateRating.ratingValue | tonumber),
54
+ review_count: .aggregateRating.reviewCount,
55
+ best_rating: .aggregateRating.bestRating,
56
+ telephone, image,
57
+ latitude: .geo.latitude, longitude: .geo.longitude,
58
+ same_as: .sameAs, address
59
+ }'
60
+ ```
61
+
62
+ Example fields (attraction, `d104675`):
63
+
64
+ ```jsonc
65
+ {
66
+ "@type": "LocalBusiness",
67
+ "name": "Golden Gate Bridge",
68
+ "url": "https://www.tripadvisor.com/Attraction_Review-g60713-d104675-...html",
69
+ "address": {"addressLocality": "San Francisco", "addressRegion": "California", "addressCountry": "US", "postalCode": "94129"},
70
+ "aggregateRating": {"ratingValue": "4.7", "reviewCount": 49969, "bestRating": 5},
71
+ "image": "https://dynamic-media-cdn.tripadvisor.com/media/photo-o/.../golden-gate-bridge.jpg?...",
72
+ "telephone": "+1 415-921-5858",
73
+ "geo": {"latitude": 37.820026, "longitude": -122.47859},
74
+ "sameAs": "https://www.goldengate.org/"
75
+ }
76
+ ```
77
+
78
+ ## Limits
79
+
80
+ - No key-free search/typeahead endpoint exists — every plausible one
81
+ (`/TypeAheadJson`, `/data/1.0/typeahead`, `/api/internal/1.14/typeahead`,
82
+ `/Search?q=`) is a dead end (empty body, 404, needs auth, or a hydrated
83
+ SPA shell with no SSR data). **Resolve a `locationId` via the Terra
84
+ `GET /locations/search` endpoint** (`references/terra-endpoints.md` §1),
85
+ or take it from a TripAdvisor URL, then use this fallback for detail.
86
+ - Individual review **text** is not in the ld+json (no `Review` schema
87
+ block, no Apollo/redux store) — only the aggregate rating/count. Use the
88
+ Terra `GET /locations/{id}/reviews` endpoint for review text.
89
+ - If the fetched body isn't valid HTML with an ld+json block (a
90
+ bot-challenge interstitial slipped through), re-open/refresh the
91
+ `www.tripadvisor.com` tab and retry.
File without changes