@chrischall/tripadvisor-mcp 0.2.1 → 0.3.1
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/dist/bundle.js +150 -33
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
- package/skills/tripadvisor-api/SKILL.md +127 -0
- package/skills/tripadvisor-api/references/terra-endpoints.md +118 -0
- package/skills/tripadvisor-api/references/web-fallback.md +91 -0
- /package/{SKILL.md → skills/tripadvisor/SKILL.md} +0 -0
|
@@ -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.
|
|
10
|
+
"version": "0.3.1"
|
|
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.
|
|
18
|
+
"version": "0.3.1",
|
|
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.
|
|
4
|
+
"version": "0.3.1",
|
|
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": "./
|
|
22
|
+
"skills": "./skills/",
|
|
23
23
|
"mcp": "./.mcp.json"
|
|
24
24
|
}
|
package/dist/bundle.js
CHANGED
|
@@ -3776,6 +3776,7 @@ var require_fast_uri = __commonJS({
|
|
|
3776
3776
|
return uriTokens.join("");
|
|
3777
3777
|
}
|
|
3778
3778
|
var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
|
|
3779
|
+
var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
|
|
3779
3780
|
function getParseError(parsed, matches) {
|
|
3780
3781
|
if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
|
|
3781
3782
|
return 'URI path must start with "/" when authority is present.';
|
|
@@ -3805,6 +3806,11 @@ var require_fast_uri = __commonJS({
|
|
|
3805
3806
|
uri = "//" + uri;
|
|
3806
3807
|
}
|
|
3807
3808
|
}
|
|
3809
|
+
const authorityMatch = uri.match(AUTHORITY_PREFIX);
|
|
3810
|
+
if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
|
|
3811
|
+
parsed.error = "URI authority must not contain a literal backslash.";
|
|
3812
|
+
malformedAuthorityOrPort = true;
|
|
3813
|
+
}
|
|
3808
3814
|
const matches = uri.match(URI_PARSE);
|
|
3809
3815
|
if (matches) {
|
|
3810
3816
|
parsed.scheme = matches[1];
|
|
@@ -7693,6 +7699,7 @@ var require_receiver = __commonJS({
|
|
|
7693
7699
|
this._opcode = 0;
|
|
7694
7700
|
this._totalPayloadLength = 0;
|
|
7695
7701
|
this._messageLength = 0;
|
|
7702
|
+
this._numFragments = 0;
|
|
7696
7703
|
this._fragments = [];
|
|
7697
7704
|
this._errored = false;
|
|
7698
7705
|
this._loop = false;
|
|
@@ -8043,23 +8050,23 @@ var require_receiver = __commonJS({
|
|
|
8043
8050
|
this.controlMessage(data, cb);
|
|
8044
8051
|
return;
|
|
8045
8052
|
}
|
|
8053
|
+
if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
|
|
8054
|
+
const error51 = this.createError(
|
|
8055
|
+
RangeError,
|
|
8056
|
+
"Too many message fragments",
|
|
8057
|
+
false,
|
|
8058
|
+
1008,
|
|
8059
|
+
"WS_ERR_TOO_MANY_BUFFERED_PARTS"
|
|
8060
|
+
);
|
|
8061
|
+
cb(error51);
|
|
8062
|
+
return;
|
|
8063
|
+
}
|
|
8046
8064
|
if (this._compressed) {
|
|
8047
8065
|
this._state = INFLATING;
|
|
8048
8066
|
this.decompress(data, cb);
|
|
8049
8067
|
return;
|
|
8050
8068
|
}
|
|
8051
8069
|
if (data.length) {
|
|
8052
|
-
if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
|
|
8053
|
-
const error51 = this.createError(
|
|
8054
|
-
RangeError,
|
|
8055
|
-
"Too many message fragments",
|
|
8056
|
-
false,
|
|
8057
|
-
1008,
|
|
8058
|
-
"WS_ERR_TOO_MANY_BUFFERED_PARTS"
|
|
8059
|
-
);
|
|
8060
|
-
cb(error51);
|
|
8061
|
-
return;
|
|
8062
|
-
}
|
|
8063
8070
|
this._messageLength = this._totalPayloadLength;
|
|
8064
8071
|
this._fragments.push(data);
|
|
8065
8072
|
}
|
|
@@ -8089,17 +8096,6 @@ var require_receiver = __commonJS({
|
|
|
8089
8096
|
cb(error51);
|
|
8090
8097
|
return;
|
|
8091
8098
|
}
|
|
8092
|
-
if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
|
|
8093
|
-
const error51 = this.createError(
|
|
8094
|
-
RangeError,
|
|
8095
|
-
"Too many message fragments",
|
|
8096
|
-
false,
|
|
8097
|
-
1008,
|
|
8098
|
-
"WS_ERR_TOO_MANY_BUFFERED_PARTS"
|
|
8099
|
-
);
|
|
8100
|
-
cb(error51);
|
|
8101
|
-
return;
|
|
8102
|
-
}
|
|
8103
8099
|
this._fragments.push(buf);
|
|
8104
8100
|
}
|
|
8105
8101
|
this.dataMessage(cb);
|
|
@@ -8122,6 +8118,7 @@ var require_receiver = __commonJS({
|
|
|
8122
8118
|
this._totalPayloadLength = 0;
|
|
8123
8119
|
this._messageLength = 0;
|
|
8124
8120
|
this._fragmented = 0;
|
|
8121
|
+
this._numFragments = 0;
|
|
8125
8122
|
this._fragments = [];
|
|
8126
8123
|
if (this._opcode === 2) {
|
|
8127
8124
|
let data;
|
|
@@ -9622,8 +9619,8 @@ var require_websocket = __commonJS({
|
|
|
9622
9619
|
autoPong: true,
|
|
9623
9620
|
closeTimeout: CLOSE_TIMEOUT,
|
|
9624
9621
|
protocolVersion: protocolVersions[1],
|
|
9625
|
-
maxBufferedChunks:
|
|
9626
|
-
maxFragments:
|
|
9622
|
+
maxBufferedChunks: 256 * 1024,
|
|
9623
|
+
maxFragments: 16 * 1024,
|
|
9627
9624
|
maxPayload: 100 * 1024 * 1024,
|
|
9628
9625
|
skipUTF8Validation: false,
|
|
9629
9626
|
perMessageDeflate: true,
|
|
@@ -10210,9 +10207,9 @@ var require_websocket_server = __commonJS({
|
|
|
10210
10207
|
* called
|
|
10211
10208
|
* @param {Function} [options.handleProtocols] A hook to handle protocols
|
|
10212
10209
|
* @param {String} [options.host] The hostname where to bind the server
|
|
10213
|
-
* @param {Number} [options.maxBufferedChunks=
|
|
10210
|
+
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
|
10214
10211
|
* buffered data chunks
|
|
10215
|
-
* @param {Number} [options.maxFragments=
|
|
10212
|
+
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
|
10216
10213
|
* fragments
|
|
10217
10214
|
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
|
10218
10215
|
* size
|
|
@@ -10235,8 +10232,8 @@ var require_websocket_server = __commonJS({
|
|
|
10235
10232
|
options = {
|
|
10236
10233
|
allowSynchronousEvents: true,
|
|
10237
10234
|
autoPong: true,
|
|
10238
|
-
maxBufferedChunks:
|
|
10239
|
-
maxFragments:
|
|
10235
|
+
maxBufferedChunks: 256 * 1024,
|
|
10236
|
+
maxFragments: 16 * 1024,
|
|
10240
10237
|
maxPayload: 100 * 1024 * 1024,
|
|
10241
10238
|
skipUTF8Validation: false,
|
|
10242
10239
|
perMessageDeflate: false,
|
|
@@ -34939,7 +34936,7 @@ var pageSchema = {
|
|
|
34939
34936
|
};
|
|
34940
34937
|
|
|
34941
34938
|
// src/version.ts
|
|
34942
|
-
var VERSION = "0.
|
|
34939
|
+
var VERSION = "0.3.1";
|
|
34943
34940
|
|
|
34944
34941
|
// src/client.ts
|
|
34945
34942
|
import { dirname, join } from "node:path";
|
|
@@ -35273,6 +35270,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
35273
35270
|
"capture_request_header",
|
|
35274
35271
|
"capture_redirect",
|
|
35275
35272
|
"read_indexed_db",
|
|
35273
|
+
"read_dom",
|
|
35276
35274
|
"download"
|
|
35277
35275
|
]);
|
|
35278
35276
|
|
|
@@ -35574,6 +35572,44 @@ function assertIndexedDbScopesArray(value, label) {
|
|
|
35574
35572
|
}
|
|
35575
35573
|
}
|
|
35576
35574
|
}
|
|
35575
|
+
var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
|
|
35576
|
+
var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
|
|
35577
|
+
function assertDomSelectorsArray(value, label) {
|
|
35578
|
+
if (!Array.isArray(value)) {
|
|
35579
|
+
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
35580
|
+
}
|
|
35581
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35582
|
+
for (let i = 0; i < value.length; i++) {
|
|
35583
|
+
const entry = value[i];
|
|
35584
|
+
assertObject(entry, `${label}[${i}]`);
|
|
35585
|
+
if (entry.name === void 0) {
|
|
35586
|
+
throw new ProtocolError(`${label}[${i}].name: missing`);
|
|
35587
|
+
}
|
|
35588
|
+
if (entry.selector === void 0) {
|
|
35589
|
+
throw new ProtocolError(`${label}[${i}].selector: missing`);
|
|
35590
|
+
}
|
|
35591
|
+
if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
|
|
35592
|
+
throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
|
|
35593
|
+
}
|
|
35594
|
+
if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
|
|
35595
|
+
throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
|
|
35596
|
+
}
|
|
35597
|
+
if (entry.attribute !== void 0) {
|
|
35598
|
+
if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
|
|
35599
|
+
throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
|
|
35600
|
+
}
|
|
35601
|
+
}
|
|
35602
|
+
if (seen.has(entry.name)) {
|
|
35603
|
+
throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
|
|
35604
|
+
}
|
|
35605
|
+
seen.add(entry.name);
|
|
35606
|
+
for (const k of Object.keys(entry)) {
|
|
35607
|
+
if (k !== "name" && k !== "selector" && k !== "attribute") {
|
|
35608
|
+
throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
|
|
35609
|
+
}
|
|
35610
|
+
}
|
|
35611
|
+
}
|
|
35612
|
+
}
|
|
35577
35613
|
function validateFrame(raw) {
|
|
35578
35614
|
assertObject(raw, "frame");
|
|
35579
35615
|
const t = raw.type;
|
|
@@ -35649,6 +35685,9 @@ function validateHello(raw) {
|
|
|
35649
35685
|
if (raw.sessionStoragePointers !== void 0) {
|
|
35650
35686
|
assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
|
|
35651
35687
|
}
|
|
35688
|
+
if (raw.domSelectors !== void 0) {
|
|
35689
|
+
assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
|
|
35690
|
+
}
|
|
35652
35691
|
assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
|
|
35653
35692
|
assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
|
|
35654
35693
|
assertBase64(raw.sessionNonce, "hello.sessionNonce");
|
|
@@ -35883,6 +35922,21 @@ function validateInnerRequest(raw) {
|
|
|
35883
35922
|
}
|
|
35884
35923
|
return raw;
|
|
35885
35924
|
}
|
|
35925
|
+
if (raw.op === "read_dom") {
|
|
35926
|
+
assertObject(raw.init, "inner.init");
|
|
35927
|
+
if (raw.init.origin === void 0)
|
|
35928
|
+
throw new ProtocolError("inner.init.origin: missing");
|
|
35929
|
+
if (raw.init.names === void 0)
|
|
35930
|
+
throw new ProtocolError("inner.init.names: missing");
|
|
35931
|
+
assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
|
|
35932
|
+
assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
|
|
35933
|
+
for (const k of Object.keys(raw.init)) {
|
|
35934
|
+
if (k !== "origin" && k !== "names") {
|
|
35935
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
|
|
35936
|
+
}
|
|
35937
|
+
}
|
|
35938
|
+
return raw;
|
|
35939
|
+
}
|
|
35886
35940
|
if (raw.op === "download") {
|
|
35887
35941
|
assertObject(raw.init, "inner.init");
|
|
35888
35942
|
if (raw.init.url === void 0) {
|
|
@@ -35908,7 +35962,7 @@ function validateInnerRequest(raw) {
|
|
|
35908
35962
|
}
|
|
35909
35963
|
return raw;
|
|
35910
35964
|
}
|
|
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)}`);
|
|
35965
|
+
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
35966
|
}
|
|
35913
35967
|
function assertNonEmptyKeyArray(value, label) {
|
|
35914
35968
|
if (!Array.isArray(value)) {
|
|
@@ -35993,6 +36047,13 @@ function validateInnerResponse(raw) {
|
|
|
35993
36047
|
assertObject(raw.values, "inner.values");
|
|
35994
36048
|
return raw;
|
|
35995
36049
|
}
|
|
36050
|
+
if (op === "read_dom") {
|
|
36051
|
+
if (raw.values === void 0) {
|
|
36052
|
+
throw new ProtocolError("inner.values: missing on read_dom response");
|
|
36053
|
+
}
|
|
36054
|
+
assertStringMap(raw.values, "inner.values");
|
|
36055
|
+
return raw;
|
|
36056
|
+
}
|
|
35996
36057
|
if (op === "download") {
|
|
35997
36058
|
assertObject(raw.value, "inner.value");
|
|
35998
36059
|
assertString(raw.value.path, "inner.value.path");
|
|
@@ -36332,6 +36393,13 @@ async function buildServerHello(opts) {
|
|
|
36332
36393
|
jsonPointer: d.jsonPointer
|
|
36333
36394
|
}));
|
|
36334
36395
|
}
|
|
36396
|
+
if (opts.domSelectors && opts.domSelectors.length > 0) {
|
|
36397
|
+
hello.domSelectors = opts.domSelectors.map((d) => ({
|
|
36398
|
+
name: d.name,
|
|
36399
|
+
selector: d.selector,
|
|
36400
|
+
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36401
|
+
}));
|
|
36402
|
+
}
|
|
36335
36403
|
return hello;
|
|
36336
36404
|
}
|
|
36337
36405
|
|
|
@@ -36421,7 +36489,8 @@ async function startHost(opts) {
|
|
|
36421
36489
|
captureHeaders: opts.ownCaptureHeaders,
|
|
36422
36490
|
indexedDbScopes: opts.ownIndexedDbScopes,
|
|
36423
36491
|
localStoragePointers: opts.ownLocalStoragePointers,
|
|
36424
|
-
sessionStoragePointers: opts.ownSessionStoragePointers
|
|
36492
|
+
sessionStoragePointers: opts.ownSessionStoragePointers,
|
|
36493
|
+
domSelectors: opts.ownDomSelectors
|
|
36425
36494
|
});
|
|
36426
36495
|
const ownSessionNonce = fromB64(ownHello.sessionNonce);
|
|
36427
36496
|
let extensionWs = null;
|
|
@@ -36656,6 +36725,7 @@ async function startPeer(opts) {
|
|
|
36656
36725
|
sessionStorageKeys: opts.sessionStorageKeys,
|
|
36657
36726
|
captureHeaders: opts.captureHeaders,
|
|
36658
36727
|
indexedDbScopes: opts.indexedDbScopes,
|
|
36728
|
+
domSelectors: opts.domSelectors,
|
|
36659
36729
|
localStoragePointers: opts.localStoragePointers,
|
|
36660
36730
|
sessionStoragePointers: opts.sessionStoragePointers
|
|
36661
36731
|
});
|
|
@@ -37063,6 +37133,11 @@ var FetchproxyServer = class {
|
|
|
37063
37133
|
key: d.key,
|
|
37064
37134
|
jsonPointer: d.jsonPointer
|
|
37065
37135
|
})),
|
|
37136
|
+
domSelectors: (opts.domSelectors ?? []).map((d) => ({
|
|
37137
|
+
name: d.name,
|
|
37138
|
+
selector: d.selector,
|
|
37139
|
+
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
37140
|
+
})),
|
|
37066
37141
|
// 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
|
|
37067
37142
|
// adapter was about to set these to the same numbers anyway; the
|
|
37068
37143
|
// back-door is `0` (explicit opt-out) if a caller genuinely wants
|
|
@@ -37183,6 +37258,7 @@ var FetchproxyServer = class {
|
|
|
37183
37258
|
ownIndexedDbScopes: this.opts.indexedDbScopes,
|
|
37184
37259
|
ownLocalStoragePointers: this.opts.localStoragePointers,
|
|
37185
37260
|
ownSessionStoragePointers: this.opts.sessionStoragePointers,
|
|
37261
|
+
ownDomSelectors: this.opts.domSelectors,
|
|
37186
37262
|
onPairCode: this.opts.onPairCode
|
|
37187
37263
|
});
|
|
37188
37264
|
this.hostHandle.onOwnInner((inner) => this.onInner(inner));
|
|
@@ -37210,7 +37286,8 @@ var FetchproxyServer = class {
|
|
|
37210
37286
|
captureHeaders: this.opts.captureHeaders,
|
|
37211
37287
|
indexedDbScopes: this.opts.indexedDbScopes,
|
|
37212
37288
|
localStoragePointers: this.opts.localStoragePointers,
|
|
37213
|
-
sessionStoragePointers: this.opts.sessionStoragePointers
|
|
37289
|
+
sessionStoragePointers: this.opts.sessionStoragePointers,
|
|
37290
|
+
domSelectors: this.opts.domSelectors
|
|
37214
37291
|
});
|
|
37215
37292
|
this.peerHandle.onInner((inner) => this.onInner(inner));
|
|
37216
37293
|
this.peerHandle.onRenegotiate(() => {
|
|
@@ -38185,6 +38262,46 @@ var FetchproxyServer = class {
|
|
|
38185
38262
|
await this.sendInnerFrame(inner);
|
|
38186
38263
|
return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
|
|
38187
38264
|
}
|
|
38265
|
+
/**
|
|
38266
|
+
* 1.4.0+: read declared DOM values from the user's signed-in tab.
|
|
38267
|
+
* Requires `'read_dom'` in capabilities AND every requested `name` to
|
|
38268
|
+
* match a declared `domSelectors` entry. The extension reads each
|
|
38269
|
+
* declared selector from the matched tab's DOM (isolated-world
|
|
38270
|
+
* `querySelector`, value or attribute) — no page-JS execution.
|
|
38271
|
+
*
|
|
38272
|
+
* Returns a `Record<string, string>` of `name → value`, with names
|
|
38273
|
+
* whose element (or attribute) was absent omitted. Throws
|
|
38274
|
+
* `FetchproxyProtocolError` on bridge failures and a plain `Error` on
|
|
38275
|
+
* developer mistakes (undeclared capability, undeclared name).
|
|
38276
|
+
*/
|
|
38277
|
+
async readDom(opts) {
|
|
38278
|
+
if (!this.opts.capabilities.includes("read_dom")) {
|
|
38279
|
+
throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
|
|
38280
|
+
}
|
|
38281
|
+
await this.ensureConnected();
|
|
38282
|
+
this.throwIfPendingPair();
|
|
38283
|
+
if (!Array.isArray(opts.names) || opts.names.length === 0) {
|
|
38284
|
+
throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
|
|
38285
|
+
}
|
|
38286
|
+
this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
|
|
38287
|
+
if (opts.subdomain !== void 0)
|
|
38288
|
+
assertSubdomainLabel(opts.subdomain);
|
|
38289
|
+
const baseDomain = this.resolveBaseDomain(opts.domain);
|
|
38290
|
+
const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
|
|
38291
|
+
const origin = `https://${host}`;
|
|
38292
|
+
const id = this.nextRequestId++;
|
|
38293
|
+
const inner = {
|
|
38294
|
+
type: "request",
|
|
38295
|
+
id,
|
|
38296
|
+
op: "read_dom",
|
|
38297
|
+
init: { origin, names: [...opts.names] }
|
|
38298
|
+
};
|
|
38299
|
+
const pending = new Promise((resolve, reject) => {
|
|
38300
|
+
this.pendingStorage.set(id, { resolve, reject });
|
|
38301
|
+
});
|
|
38302
|
+
await this.sendInnerFrame(inner);
|
|
38303
|
+
return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
|
|
38304
|
+
}
|
|
38188
38305
|
assertScopeSubset(requested, declared, label) {
|
|
38189
38306
|
const undeclared = undeclaredKeys(requested, declared);
|
|
38190
38307
|
if (undeclared.length > 0) {
|
|
@@ -38256,7 +38373,7 @@ var FetchproxyServer = class {
|
|
|
38256
38373
|
if (storageCb) {
|
|
38257
38374
|
this.pendingStorage.delete(inner.id);
|
|
38258
38375
|
if (inner.ok) {
|
|
38259
|
-
if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
|
|
38376
|
+
if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
|
|
38260
38377
|
storageCb.resolve({ ...inner.values });
|
|
38261
38378
|
} else {
|
|
38262
38379
|
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
|
+
export const VERSION = '0.3.1'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrischall/tripadvisor-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
-
"
|
|
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.
|
|
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": "^
|
|
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.
|
|
9
|
+
"version": "0.3.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/tripadvisor-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.3.1",
|
|
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
|