@feralfile/cli 1.1.4 → 1.2.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.
- package/README.md +26 -0
- package/dist/index.js +4 -2
- package/dist/package.json +84 -0
- package/dist/src/ai-orchestrator/index.js +14 -2
- package/dist/src/commands/build.js +3 -3
- package/dist/src/commands/chat.js +3 -3
- package/dist/src/commands/config.js +2 -2
- package/dist/src/commands/device.js +6 -6
- package/dist/src/commands/find.js +502 -0
- package/dist/src/commands/helpers/device-discovery.js +1 -1
- package/dist/src/commands/play.js +52 -11
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/commands/status.js +2 -2
- package/dist/src/intent-parser/index.js +16 -5
- package/dist/src/intent-parser/utils.js +7 -2
- package/dist/src/main.js +61 -17
- package/dist/src/utilities/ab-marketplace.js +95 -0
- package/dist/src/utilities/feral-file-artwork.js +89 -0
- package/dist/src/utilities/ff-marketplace.js +115 -0
- package/dist/src/utilities/ff1-device.js +4 -4
- package/dist/src/utilities/functions.js +1 -1
- package/dist/src/utilities/fxhash-marketplace.js +146 -0
- package/dist/src/utilities/index.js +80 -1
- package/dist/src/utilities/marketplace-url.js +373 -0
- package/dist/src/utilities/neort-marketplace.js +102 -0
- package/dist/src/utilities/nft-indexer.js +22 -14
- package/dist/src/utilities/objkt-marketplace.js +77 -0
- package/dist/src/utilities/playlist-publisher.js +9 -42
- package/dist/src/utilities/raster-client.js +252 -0
- package/dist/src/utilities/user-agent.js +20 -0
- package/docs/CONFIGURATION.md +3 -1
- package/docs/EXAMPLES.md +23 -0
- package/docs/FUNCTION_CALLING.md +3 -1
- package/docs/README.md +11 -10
- package/docs/RELEASING.md +92 -22
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* fxhash marketplace resolver.
|
|
4
|
+
*
|
|
5
|
+
* fxhash's UI exposes:
|
|
6
|
+
* - individual iterations under `/iteration/{slug}` (e.g. `garden-monoliths-215`)
|
|
7
|
+
* — resolved via `objkt(slug:)` to (contract, onChainId).
|
|
8
|
+
* - generative projects (series) under `/project/{slug}` and `/generative/{slug}`
|
|
9
|
+
* — resolved via `generativeToken(slug:)` to (contract, first iteration's
|
|
10
|
+
* onChainId), which Raster then reverse-looks-up to enumerate the rest of
|
|
11
|
+
* the series. Mirrors the AB collection flow.
|
|
12
|
+
*
|
|
13
|
+
* Tezos-only: FX1-prefixed gentks live on Tezos. FX2 (EVM) tokens / projects
|
|
14
|
+
* are out of scope. EVM gentk URLs are caught earlier in the URL parser; EVM
|
|
15
|
+
* projects under `/project/{slug}` will simply not resolve via the Tezos
|
|
16
|
+
* GraphQL endpoint and surface as "slug not found".
|
|
17
|
+
*/
|
|
18
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
21
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
22
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
23
|
+
}
|
|
24
|
+
Object.defineProperty(o, k2, desc);
|
|
25
|
+
}) : (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
o[k2] = m[k];
|
|
28
|
+
}));
|
|
29
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
30
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
31
|
+
}) : function(o, v) {
|
|
32
|
+
o["default"] = v;
|
|
33
|
+
});
|
|
34
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
35
|
+
var ownKeys = function(o) {
|
|
36
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
37
|
+
var ar = [];
|
|
38
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
39
|
+
return ar;
|
|
40
|
+
};
|
|
41
|
+
return ownKeys(o);
|
|
42
|
+
};
|
|
43
|
+
return function (mod) {
|
|
44
|
+
if (mod && mod.__esModule) return mod;
|
|
45
|
+
var result = {};
|
|
46
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
47
|
+
__setModuleDefault(result, mod);
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
})();
|
|
51
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
+
exports.resolveFxhashIteration = resolveFxhashIteration;
|
|
53
|
+
exports.resolveFxhashProject = resolveFxhashProject;
|
|
54
|
+
const logger = __importStar(require("../logger"));
|
|
55
|
+
const user_agent_1 = require("./user-agent");
|
|
56
|
+
const FXHASH_GRAPHQL = 'https://api.fxhash.xyz/graphql';
|
|
57
|
+
/**
|
|
58
|
+
* Resolve an fxhash iteration URL slug to its on-chain Tezos coordinates.
|
|
59
|
+
*
|
|
60
|
+
* @throws When the slug is unknown, the GraphQL call fails, or the resolved
|
|
61
|
+
* objkt is missing its on-chain fields (un-minted / pre-reveal).
|
|
62
|
+
*/
|
|
63
|
+
async function resolveFxhashIteration(slug) {
|
|
64
|
+
logger.debug(`[fxhash] Resolving iteration slug "${slug}"`);
|
|
65
|
+
const query = 'query ($slug: String!) { objkt(slug: $slug) { gentkContractAddress onChainId } }';
|
|
66
|
+
const response = await fetch(FXHASH_GRAPHQL, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
|
|
69
|
+
body: JSON.stringify({ query, variables: { slug } }),
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new Error(`fxhash GraphQL ${response.status} ${response.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
const data = (await response.json());
|
|
75
|
+
if (data.errors && data.errors.length > 0) {
|
|
76
|
+
throw new Error(`fxhash GraphQL errors: ${data.errors.map((e) => e.message).join('; ')}`);
|
|
77
|
+
}
|
|
78
|
+
const objkt = data.data?.objkt;
|
|
79
|
+
if (!objkt) {
|
|
80
|
+
throw new Error(`fxhash: no iteration found for slug "${slug}". ` +
|
|
81
|
+
'Check the URL — slugs come from fxhash.xyz/iteration/{slug}. ' +
|
|
82
|
+
'Pre-reveal or unminted iterations may not be queryable yet.');
|
|
83
|
+
}
|
|
84
|
+
if (!objkt.gentkContractAddress || objkt.onChainId === null) {
|
|
85
|
+
throw new Error(`fxhash: iteration "${slug}" is missing on-chain coordinates ` +
|
|
86
|
+
'(gentkContractAddress / onChainId). It may be pre-reveal or unassigned.');
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
chain: 'tezos',
|
|
90
|
+
contract: objkt.gentkContractAddress,
|
|
91
|
+
tokenId: String(objkt.onChainId),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve an fxhash generative project slug (e.g. `garden-monoliths`) to its
|
|
96
|
+
* contract + a representative on-chain tokenId — the first minted iteration.
|
|
97
|
+
* Raster then enumerates the rest of the series via the standard reverse
|
|
98
|
+
* lookup, identical to the AB collection flow.
|
|
99
|
+
*
|
|
100
|
+
* Note: each Objkt has its own `gentkContractAddress`. fxhash projects can
|
|
101
|
+
* span multiple gentk contracts (V1/V2/V3 mint phases), so we read the contract
|
|
102
|
+
* off the iteration itself, not the project header.
|
|
103
|
+
*
|
|
104
|
+
* @throws When the slug is unknown (Tezos endpoint won't see EVM-only
|
|
105
|
+
* projects), the project has no minted iterations, or the GraphQL call
|
|
106
|
+
* fails.
|
|
107
|
+
*/
|
|
108
|
+
async function resolveFxhashProject(slug) {
|
|
109
|
+
logger.debug(`[fxhash] Resolving project slug "${slug}"`);
|
|
110
|
+
const query = 'query ($slug: String!) {' +
|
|
111
|
+
' generativeToken(slug: $slug) {' +
|
|
112
|
+
' objkts(skip: 0, take: 1, sort: { iteration: "ASC" }) {' +
|
|
113
|
+
' gentkContractAddress' +
|
|
114
|
+
' onChainId' +
|
|
115
|
+
' }' +
|
|
116
|
+
' }' +
|
|
117
|
+
'}';
|
|
118
|
+
const response = await fetch(FXHASH_GRAPHQL, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
|
|
121
|
+
body: JSON.stringify({ query, variables: { slug } }),
|
|
122
|
+
});
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`fxhash GraphQL ${response.status} ${response.statusText}`);
|
|
125
|
+
}
|
|
126
|
+
const data = (await response.json());
|
|
127
|
+
if (data.errors && data.errors.length > 0) {
|
|
128
|
+
throw new Error(`fxhash GraphQL errors: ${data.errors.map((e) => e.message).join('; ')}`);
|
|
129
|
+
}
|
|
130
|
+
const project = data.data?.generativeToken;
|
|
131
|
+
if (!project) {
|
|
132
|
+
throw new Error(`fxhash: no project found for slug "${slug}". ` +
|
|
133
|
+
'Check the URL — slugs come from fxhash.xyz/project/{slug} or /generative/{slug}. ' +
|
|
134
|
+
'EVM-only projects on fxhash are not covered (FF indexer is Tezos mainnet only for fxhash).');
|
|
135
|
+
}
|
|
136
|
+
const firstObjkt = project.objkts?.[0];
|
|
137
|
+
if (!firstObjkt || !firstObjkt.gentkContractAddress || firstObjkt.onChainId === null) {
|
|
138
|
+
throw new Error(`fxhash: project "${slug}" has no minted iterations yet, or is pre-reveal. ` +
|
|
139
|
+
'Paste a specific iteration URL once at least one edition is minted.');
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
chain: 'tezos',
|
|
143
|
+
contract: firstObjkt.gentkContractAddress,
|
|
144
|
+
tokenId: String(firstObjkt.onChainId),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
* Utilities - Actual function implementations
|
|
3
3
|
* Contains the real logic for querying NFTs and building playlists
|
|
4
4
|
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
5
38
|
const chalk = require('chalk');
|
|
6
39
|
const nftIndexer = require('./nft-indexer');
|
|
7
40
|
const feedFetcher = require('./feed-fetcher');
|
|
@@ -214,7 +247,7 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, optio
|
|
|
214
247
|
* @returns {Promise<Array>} Array of DP1 playlist items
|
|
215
248
|
*/
|
|
216
249
|
async function queryRequirement(requirement, duration = 10) {
|
|
217
|
-
const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity } = requirement;
|
|
250
|
+
const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity, artworkId, } = requirement;
|
|
218
251
|
// Handle query_address type
|
|
219
252
|
if (type === 'query_address') {
|
|
220
253
|
// Check if ownerAddress is a domain name (.eth or .tez)
|
|
@@ -262,6 +295,19 @@ async function queryRequirement(requirement, duration = 10) {
|
|
|
262
295
|
return [];
|
|
263
296
|
}
|
|
264
297
|
}
|
|
298
|
+
if (type === 'feral_file_artwork') {
|
|
299
|
+
console.log(chalk.cyan(`Resolving Feral File artwork ${artworkId}...`));
|
|
300
|
+
const { parseFeralFileArtworkId, resolveFeralFileArtwork } = require('./feral-file-artwork');
|
|
301
|
+
const coords = await resolveFeralFileArtwork(parseFeralFileArtworkId(artworkId));
|
|
302
|
+
const items = await nftIndexer.getNFTTokenInfoBatch([coords], duration);
|
|
303
|
+
if (items.length > 0) {
|
|
304
|
+
console.log(chalk.green(`✓ Got ${items.length} item(s)`));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
console.log(chalk.yellow(' No items found for Feral File artwork'));
|
|
308
|
+
}
|
|
309
|
+
return items;
|
|
310
|
+
}
|
|
265
311
|
// Handle build_playlist type (original NFT querying logic)
|
|
266
312
|
console.log(chalk.cyan(`Querying ${blockchain}${contractAddress ? ' (' + contractAddress.substring(0, 10) + '...)' : ''}...`));
|
|
267
313
|
let items = [];
|
|
@@ -343,11 +389,39 @@ async function buildDP1Playlist(items, title, slug) {
|
|
|
343
389
|
/**
|
|
344
390
|
* Send playlist to FF1 device
|
|
345
391
|
*
|
|
392
|
+
* This gateway verifies the playlist before any device transport happens.
|
|
393
|
+
* Callers in the AI orchestrator and deterministic build path both route
|
|
394
|
+
* through here, so the delivery policy stays consistent across the CLI.
|
|
395
|
+
* TODO: Propagate structured send failures consistently from every caller;
|
|
396
|
+
* buildPlaylistDirect currently preserves the pre-existing non-fatal behavior.
|
|
397
|
+
*
|
|
346
398
|
* @param {Object} playlist - DP1 playlist
|
|
347
399
|
* @param {string} [deviceName] - Device name
|
|
348
400
|
* @returns {Promise<Object>} Result
|
|
349
401
|
*/
|
|
350
402
|
async function sendToDevice(playlist, deviceName) {
|
|
403
|
+
const playlistVerifier = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
|
|
404
|
+
const verifyPlaylist = playlistVerifier.verifyPlaylist ||
|
|
405
|
+
(playlistVerifier.default && playlistVerifier.default.verifyPlaylist) ||
|
|
406
|
+
playlistVerifier.default;
|
|
407
|
+
if (typeof verifyPlaylist !== 'function') {
|
|
408
|
+
return {
|
|
409
|
+
success: false,
|
|
410
|
+
error: 'Playlist verifier is not available',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const verificationResult = await verifyPlaylist(playlist);
|
|
414
|
+
if (!verificationResult.valid) {
|
|
415
|
+
return {
|
|
416
|
+
success: false,
|
|
417
|
+
error: verificationResult.error || 'Failed to verify playlist before sending',
|
|
418
|
+
details: verificationResult.details
|
|
419
|
+
? verificationResult.details
|
|
420
|
+
.map((detail) => `• ${detail.path}: ${detail.message}`)
|
|
421
|
+
.join('\n')
|
|
422
|
+
: undefined,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
351
425
|
const { sendPlaylistToDevice } = require('./functions');
|
|
352
426
|
return await sendPlaylistToDevice({ playlist, deviceName });
|
|
353
427
|
}
|
|
@@ -367,6 +441,11 @@ function shuffleArray(array) {
|
|
|
367
441
|
/**
|
|
368
442
|
* Build playlist directly from requirements (deterministic, no AI)
|
|
369
443
|
*
|
|
444
|
+
* TODO: Treat requested device send and feed publish failures consistently.
|
|
445
|
+
* This function currently keeps the pre-existing main-branch behavior where
|
|
446
|
+
* send/publish failures are non-fatal to the playlist build; fix that in a
|
|
447
|
+
* dedicated follow-up when the command contract changes.
|
|
448
|
+
*
|
|
370
449
|
* @param {Object} params - Playlist parameters
|
|
371
450
|
* @param {Array<Object>} params.requirements - Array of requirements
|
|
372
451
|
* @param {Object} params.playlistSettings - Playlist settings
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Marketplace URL parser for `ff-cli find`.
|
|
4
|
+
*
|
|
5
|
+
* Accepts user input in any of these forms and returns a normalized result:
|
|
6
|
+
* - Marketplace token URL (Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort)
|
|
7
|
+
* - Raw on-chain coordinates: "ethereum:0xabc...:123" / "tezos:KT1...:456"
|
|
8
|
+
* - Wallet address: 0x{40-hex} (Ethereum) or tz1/tz2/tz3{33} (Tezos)
|
|
9
|
+
*
|
|
10
|
+
* The unified entry point is {@link parseFindInput}. Per-marketplace parsers
|
|
11
|
+
* are exposed for unit testing.
|
|
12
|
+
*
|
|
13
|
+
* Series URLs (fxhash `/generative/...`, Feral File `/artworks/...`,
|
|
14
|
+
* Art Blocks `/projects/...`) are recognized but not resolved in v1 — they
|
|
15
|
+
* require a per-marketplace API call to derive a representative token. The
|
|
16
|
+
* parser returns an `unsupported` result with a clear reason so the caller
|
|
17
|
+
* can surface guidance instead of a generic "could not parse" error.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.parseFindInput = parseFindInput;
|
|
21
|
+
exports.parseMarketplaceUrl = parseMarketplaceUrl;
|
|
22
|
+
exports.parseObjkt = parseObjkt;
|
|
23
|
+
exports.parseArtBlocks = parseArtBlocks;
|
|
24
|
+
exports.parseFxhash = parseFxhash;
|
|
25
|
+
exports.parseOpenSea = parseOpenSea;
|
|
26
|
+
exports.parseSuperRare = parseSuperRare;
|
|
27
|
+
exports.parseNeort = parseNeort;
|
|
28
|
+
exports.parseFeralFile = parseFeralFile;
|
|
29
|
+
const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
|
|
30
|
+
const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
|
|
31
|
+
const RAW_COORDS = /^(ethereum|tezos):([^:]+):([^:]+)$/i;
|
|
32
|
+
/**
|
|
33
|
+
* Parse any user-supplied input for the `ff-cli find` command.
|
|
34
|
+
*
|
|
35
|
+
* Returns:
|
|
36
|
+
* - `{ kind: 'token', ... }` — successfully extracted on-chain coordinates
|
|
37
|
+
* - `{ kind: 'address', ... }` — wallet/artist address (for the artist path)
|
|
38
|
+
* - `{ kind: 'unsupported', reason }` — recognized but cannot extract a token
|
|
39
|
+
* - `null` — input does not match any recognized form; caller may show help
|
|
40
|
+
*/
|
|
41
|
+
function parseFindInput(input) {
|
|
42
|
+
const trimmed = input.trim();
|
|
43
|
+
if (!trimmed) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (ETH_ADDR.test(trimmed)) {
|
|
47
|
+
return { kind: 'address', chain: 'ethereum', address: trimmed.toLowerCase() };
|
|
48
|
+
}
|
|
49
|
+
if (TEZOS_ADDR.test(trimmed)) {
|
|
50
|
+
return { kind: 'address', chain: 'tezos', address: trimmed };
|
|
51
|
+
}
|
|
52
|
+
const rawMatch = RAW_COORDS.exec(trimmed);
|
|
53
|
+
if (rawMatch) {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'token',
|
|
56
|
+
source: 'raw',
|
|
57
|
+
coords: {
|
|
58
|
+
chain: rawMatch[1].toLowerCase(),
|
|
59
|
+
contract: normalizeContract(rawMatch[2]),
|
|
60
|
+
tokenId: rawMatch[3],
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
let parsedUrl;
|
|
65
|
+
try {
|
|
66
|
+
parsedUrl = new URL(trimmed);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return parseMarketplaceUrl(parsedUrl);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Dispatch a URL to the per-host parser. Returns `null` when the host is
|
|
75
|
+
* not one of the supported sources.
|
|
76
|
+
*/
|
|
77
|
+
function parseMarketplaceUrl(url) {
|
|
78
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
|
79
|
+
if (host === 'objkt.com' || host.endsWith('.objkt.com')) {
|
|
80
|
+
return parseObjkt(url);
|
|
81
|
+
}
|
|
82
|
+
if (host === 'artblocks.io' || host.endsWith('.artblocks.io')) {
|
|
83
|
+
return parseArtBlocks(url);
|
|
84
|
+
}
|
|
85
|
+
if (host === 'fxhash.xyz' || host.endsWith('.fxhash.xyz')) {
|
|
86
|
+
return parseFxhash(url);
|
|
87
|
+
}
|
|
88
|
+
if (host === 'feralfile.com' || host.endsWith('.feralfile.com')) {
|
|
89
|
+
return parseFeralFile(url);
|
|
90
|
+
}
|
|
91
|
+
if (host === 'opensea.io' || host.endsWith('.opensea.io')) {
|
|
92
|
+
return parseOpenSea(url);
|
|
93
|
+
}
|
|
94
|
+
if (host === 'superrare.com' || host.endsWith('.superrare.com')) {
|
|
95
|
+
return parseSuperRare(url);
|
|
96
|
+
}
|
|
97
|
+
if (host === 'neort.io' || host.endsWith('.neort.io')) {
|
|
98
|
+
return parseNeort(url);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Lowercase Ethereum contracts; leave Tezos KT1 contracts case-sensitive
|
|
104
|
+
* (base58 prefix is case-significant on Tezos).
|
|
105
|
+
*/
|
|
106
|
+
function normalizeContract(contract) {
|
|
107
|
+
return contract.startsWith('0x') ? contract.toLowerCase() : contract;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Objkt URL forms:
|
|
111
|
+
* objkt.com/tokens/{KT1...}/{tokenId} — raw FA contract
|
|
112
|
+
* objkt.com/tokens/{alias}/{tokenId} — collection alias (e.g. "hicetnunc")
|
|
113
|
+
* objkt.com/asset/{KT1...}/{tokenId} — legacy redirect
|
|
114
|
+
*
|
|
115
|
+
* Aliases are resolved to KT1 contracts via
|
|
116
|
+
* {@link ./objkt-marketplace#resolveObjktAlias} (separate file because the
|
|
117
|
+
* lookup is async).
|
|
118
|
+
*/
|
|
119
|
+
function parseObjkt(url) {
|
|
120
|
+
const direct = /^\/(?:tokens|asset)\/(KT[A-Za-z0-9]+)\/(\d+)\/?$/.exec(url.pathname);
|
|
121
|
+
if (direct) {
|
|
122
|
+
return {
|
|
123
|
+
kind: 'token',
|
|
124
|
+
source: 'objkt',
|
|
125
|
+
coords: { chain: 'tezos', contract: direct[1], tokenId: direct[2] },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const alias = /^\/(?:tokens|asset)\/([a-zA-Z][a-zA-Z0-9_-]*)\/(\d+)\/?$/.exec(url.pathname);
|
|
129
|
+
if (alias) {
|
|
130
|
+
return { kind: 'objkt-alias', alias: alias[1], tokenId: alias[2] };
|
|
131
|
+
}
|
|
132
|
+
if (url.pathname.startsWith('/collections/') || url.pathname.startsWith('/collection/')) {
|
|
133
|
+
return {
|
|
134
|
+
kind: 'unsupported',
|
|
135
|
+
reason: 'Objkt collection URLs are not yet supported in v1. Paste a specific token URL ' +
|
|
136
|
+
'(objkt.com/tokens/{contract-or-alias}/{tokenId}) or use `tezos:{contract}:{tokenId}`.',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
kind: 'unsupported',
|
|
141
|
+
reason: `Objkt URL not recognized: ${url.pathname}. Expected /tokens/{contract-or-alias}/{tokenId}.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Art Blocks URL forms:
|
|
146
|
+
* artblocks.io/token/{0xcontract}-{tokenId} — direct token
|
|
147
|
+
* artblocks.io/marketplace/collections/{0xcontract}/tokens/{id} — marketplace UI token
|
|
148
|
+
* artblocks.io/collection/{slug} — collection (series)
|
|
149
|
+
* artblocks.io/collections/{slug} — plural; 307→singular
|
|
150
|
+
*
|
|
151
|
+
* Collection slugs are resolved to (contract, first_tokenId) via
|
|
152
|
+
* {@link ./ab-marketplace#resolveArtBlocksCollection} (one Hasura GraphQL
|
|
153
|
+
* call). `/projects/{id}` legacy URLs return 404 on artblocks.io and are
|
|
154
|
+
* surfaced as a clear unsupported message.
|
|
155
|
+
*/
|
|
156
|
+
function parseArtBlocks(url) {
|
|
157
|
+
let m = /^\/token\/(0x[a-fA-F0-9]{40})-(\d+)\/?$/.exec(url.pathname);
|
|
158
|
+
if (m) {
|
|
159
|
+
return {
|
|
160
|
+
kind: 'token',
|
|
161
|
+
source: 'artblocks',
|
|
162
|
+
coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
m = /^\/marketplace\/collections\/(0x[a-fA-F0-9]{40})\/tokens\/(\d+)\/?$/.exec(url.pathname);
|
|
166
|
+
if (m) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'token',
|
|
169
|
+
source: 'artblocks',
|
|
170
|
+
coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
m = /^\/collections?\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
|
|
174
|
+
if (m) {
|
|
175
|
+
return { kind: 'ab-collection', slug: m[1] };
|
|
176
|
+
}
|
|
177
|
+
if (url.pathname.startsWith('/projects/')) {
|
|
178
|
+
return {
|
|
179
|
+
kind: 'unsupported',
|
|
180
|
+
reason: 'Art Blocks `/projects/{id}` URLs are legacy and no longer resolve on artblocks.io. ' +
|
|
181
|
+
'Paste the current `/collection/{slug}` URL or a specific token URL.',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
kind: 'unsupported',
|
|
186
|
+
reason: `Art Blocks URL not recognized: ${url.pathname}.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* fxhash URL forms (Tezos):
|
|
191
|
+
* fxhash.xyz/gentk/FX1-{KT1...}-{tokenId} (canonical token URL)
|
|
192
|
+
* fxhash.xyz/iteration/{slug} (single iteration; resolves via fxhash API)
|
|
193
|
+
* fxhash.xyz/project/{slug} (series; current UI form)
|
|
194
|
+
* fxhash.xyz/generative/{slug} (series; legacy URL form, both supported)
|
|
195
|
+
* fxhash.xyz/gentk/{numericId} (legacy — needs fxhash API)
|
|
196
|
+
*
|
|
197
|
+
* Series URLs (`/project/`, `/generative/`) resolve to the project's first
|
|
198
|
+
* minted iteration via `generativeToken(slug:)`; Raster then enumerates the
|
|
199
|
+
* rest of the series. EVM-only projects won't be found on the Tezos GraphQL
|
|
200
|
+
* endpoint and surface as a clean "slug not found" error.
|
|
201
|
+
*
|
|
202
|
+
* EVM (FX2-prefixed) gentks are not in scope: the FF indexer does not index
|
|
203
|
+
* fxhash's EVM contracts in v1.
|
|
204
|
+
*/
|
|
205
|
+
function parseFxhash(url) {
|
|
206
|
+
const m = /^\/gentk\/FX1-(KT[A-Za-z0-9]+)-(\d+)\/?$/.exec(url.pathname);
|
|
207
|
+
if (m) {
|
|
208
|
+
return {
|
|
209
|
+
kind: 'token',
|
|
210
|
+
source: 'fxhash',
|
|
211
|
+
coords: { chain: 'tezos', contract: m[1], tokenId: m[2] },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (/^\/gentk\/FX2-/.test(url.pathname)) {
|
|
215
|
+
return {
|
|
216
|
+
kind: 'unsupported',
|
|
217
|
+
reason: 'fxhash FX2 (EVM) tokens are not supported — FF indexer covers Tezos mainnet only ' +
|
|
218
|
+
'for fxhash. Paste a Tezos fxhash token or a different marketplace URL.',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (/^\/gentk\/\d+\/?$/.test(url.pathname)) {
|
|
222
|
+
return {
|
|
223
|
+
kind: 'unsupported',
|
|
224
|
+
reason: 'fxhash legacy numeric gentk URLs require an fxhash API lookup — not supported in v1. ' +
|
|
225
|
+
'Paste the FX1-{contract}-{tokenId} form or use `tezos:{contract}:{tokenId}`.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const iter = /^\/iteration\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
|
|
229
|
+
if (iter) {
|
|
230
|
+
return { kind: 'fxhash-iteration', slug: iter[1] };
|
|
231
|
+
}
|
|
232
|
+
const project = /^\/(?:project|generative)\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
|
|
233
|
+
if (project) {
|
|
234
|
+
return { kind: 'fxhash-project', slug: project[1] };
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
kind: 'unsupported',
|
|
238
|
+
reason: `fxhash URL not recognized: ${url.pathname}.`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* OpenSea URL forms:
|
|
243
|
+
* opensea.io/assets/{chain}/{contract}/{tokenId}
|
|
244
|
+
* opensea.io/item/{chain}/{contract}/{tokenId} — newer naming
|
|
245
|
+
* opensea.io/collection/{slug} — series slug (not yet)
|
|
246
|
+
*
|
|
247
|
+
* Only `ethereum` is in scope; other chains (matic, base, solana, etc.)
|
|
248
|
+
* fall outside the FF indexer's coverage. Token URLs resolve end-to-end only
|
|
249
|
+
* when Raster also indexes the underlying series — Raster's coverage of
|
|
250
|
+
* mainstream PFP collections (Azuki, BAYC, etc.) is partial.
|
|
251
|
+
*/
|
|
252
|
+
function parseOpenSea(url) {
|
|
253
|
+
const tokenMatch = /^\/(?:assets|item)\/([a-z_]+)\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
|
|
254
|
+
if (tokenMatch) {
|
|
255
|
+
const chain = tokenMatch[1];
|
|
256
|
+
if (chain !== 'ethereum') {
|
|
257
|
+
return {
|
|
258
|
+
kind: 'unsupported',
|
|
259
|
+
reason: `OpenSea ${chain} URLs aren't supported — ff-cli covers Ethereum and Tezos mainnet only. ` +
|
|
260
|
+
'Paste an Ethereum-chain OpenSea URL or a Tezos source (Objkt, fxhash).',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
kind: 'token',
|
|
265
|
+
source: 'opensea',
|
|
266
|
+
coords: { chain: 'ethereum', contract: tokenMatch[2].toLowerCase(), tokenId: tokenMatch[3] },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (url.pathname.startsWith('/collection/')) {
|
|
270
|
+
return {
|
|
271
|
+
kind: 'unsupported',
|
|
272
|
+
reason: 'OpenSea `/collection/{slug}` URLs are not yet supported in v1. Paste a specific token ' +
|
|
273
|
+
'URL (opensea.io/assets/ethereum/{contract}/{tokenId}) or use `ethereum:{contract}:{tokenId}`.',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
kind: 'unsupported',
|
|
278
|
+
reason: `OpenSea URL not recognized: ${url.pathname}.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* SuperRare URL forms (Ethereum-only platform):
|
|
283
|
+
* superrare.com/artwork/eth/{contract}/{tokenId}
|
|
284
|
+
* superrare.com/collection/{contract} — per-artist contract page, not supported in v1
|
|
285
|
+
* superrare.com/{artist}/{slug} — slug form, not supported in v1
|
|
286
|
+
*
|
|
287
|
+
* SuperRare's `/artwork/eth/...` URLs encode both the contract and tokenId
|
|
288
|
+
* directly, so no API call is needed. Token URLs resolve end-to-end only when
|
|
289
|
+
* Raster also indexes the underlying series; for 1/1 artworks the find flow
|
|
290
|
+
* falls through to a single-token playlist via the Raster 404 path.
|
|
291
|
+
*
|
|
292
|
+
* `/collection/{contract}` URLs on SuperRare point to a per-artist contract
|
|
293
|
+
* (each SuperRare artist mints from their own ERC-721). There is no direct
|
|
294
|
+
* contract→series mapping in Raster, so this is surfaced as a specific
|
|
295
|
+
* unsupported message rather than the generic "URL not recognized" fall-through.
|
|
296
|
+
*/
|
|
297
|
+
function parseSuperRare(url) {
|
|
298
|
+
const m = /^\/artwork\/eth\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
|
|
299
|
+
if (m) {
|
|
300
|
+
return {
|
|
301
|
+
kind: 'token',
|
|
302
|
+
source: 'superrare',
|
|
303
|
+
coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (/^\/collection\/(0x[a-fA-F0-9]{40})\/?$/.test(url.pathname)) {
|
|
307
|
+
return {
|
|
308
|
+
kind: 'unsupported',
|
|
309
|
+
reason: 'SuperRare `/collection/{contract}` URLs (per-artist contract pages) are not yet ' +
|
|
310
|
+
'supported in v1. Paste a specific token URL ' +
|
|
311
|
+
'(superrare.com/artwork/eth/{contract}/{tokenId}) or use ' +
|
|
312
|
+
'`ethereum:{contract}:{tokenId}`.',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
kind: 'unsupported',
|
|
317
|
+
reason: `SuperRare URL not recognized: ${url.pathname}. Expected ` +
|
|
318
|
+
'/artwork/eth/{contract}/{tokenId}. For artist-slug URLs, paste the canonical ' +
|
|
319
|
+
'/artwork/eth/... form (the SuperRare detail page links it under the artwork title).',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Neort URL forms (off-chain code-art platform):
|
|
324
|
+
* neort.io/art/{id} — art detail page; id is opaque alphanumeric (~20 chars)
|
|
325
|
+
*
|
|
326
|
+
* Neort items are not on-chain, so the find flow handles them specially:
|
|
327
|
+
* resolve via Neort's public API and build a DP-1 item directly with
|
|
328
|
+
* `provenance.offChainURI`, bypassing Raster + FF indexer entirely.
|
|
329
|
+
*/
|
|
330
|
+
function parseNeort(url) {
|
|
331
|
+
const m = /^\/art\/([a-zA-Z0-9]+)\/?$/.exec(url.pathname);
|
|
332
|
+
if (m) {
|
|
333
|
+
return { kind: 'neort-art', id: m[1] };
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
kind: 'unsupported',
|
|
337
|
+
reason: `Neort URL not recognized: ${url.pathname}. Expected /art/{id}.`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Feral File URL forms (source of truth: feralfile-client app-routing
|
|
342
|
+
* + collection-routing modules):
|
|
343
|
+
* /exhibitions/artwork/{tokenId} — single artwork by on-chain tokenId
|
|
344
|
+
* /exhibitions/series/{slug} — series detail (multi-token)
|
|
345
|
+
* /exhibitions/shows/{slug} — full exhibition (multiple series)
|
|
346
|
+
*
|
|
347
|
+
* These URLs do not include `chain` or `contract`; FF API walks are required
|
|
348
|
+
* to derive them. The parser stays sync — it returns an `ff-url` marker that
|
|
349
|
+
* the caller resolves asynchronously via `ff-marketplace#resolveFeralFileToken`.
|
|
350
|
+
* Tracked for collapse to a single GET in feral-file/ff-exhibition#3039.
|
|
351
|
+
*/
|
|
352
|
+
function parseFeralFile(url) {
|
|
353
|
+
// Public artwork ids may be numeric (legacy: URL id == on-chain tokenID) or
|
|
354
|
+
// hex hashes (swapped-id editions). Accept any URL-safe alphanumeric form;
|
|
355
|
+
// the FF API rejects unknown ids cleanly downstream.
|
|
356
|
+
let m = /^\/exhibitions\/artwork\/([A-Za-z0-9]+)\/?$/.exec(url.pathname);
|
|
357
|
+
if (m) {
|
|
358
|
+
return { kind: 'ff-url', urlKind: 'artwork', identifier: m[1] };
|
|
359
|
+
}
|
|
360
|
+
m = /^\/exhibitions\/series\/([^/]+)\/?$/.exec(url.pathname);
|
|
361
|
+
if (m) {
|
|
362
|
+
return { kind: 'ff-url', urlKind: 'series', identifier: m[1] };
|
|
363
|
+
}
|
|
364
|
+
m = /^\/exhibitions\/shows\/([^/]+)\/?$/.exec(url.pathname);
|
|
365
|
+
if (m) {
|
|
366
|
+
return { kind: 'ff-url', urlKind: 'show', identifier: m[1] };
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
kind: 'unsupported',
|
|
370
|
+
reason: `Feral File URL not recognized: ${url.pathname}. Supported: ` +
|
|
371
|
+
'/exhibitions/artwork/{tokenId}, /exhibitions/series/{slug}, /exhibitions/shows/{slug}.',
|
|
372
|
+
};
|
|
373
|
+
}
|