Semantic WebとNFT
Semantic Web(セマンティック・ウェブ)は、Web上のデータに意味(セマンティクス)を付与し、機械が理解・処理できるようにする技術体系です。RDF(Resource Description Framework)やJSON-LD(JSON for Linking Data)といった標準を使って、データの構造と関係性を明示的に記述します。
NFTのメタデータにSemantic Webの技術を適用することで、以下のような利点が得られます。
- 相互運用性: 異なるシステム間でメタデータの意味を共有できる
- 来歴の機械可読化: ブロックチェーン上の所有権移転を標準オントロジーで記述できる
- Linked Data: 他のデータセット(Wikidata、国会図書館等)とリンクできる
本章では、PROV-Oオントロジーを使ってNFTの来歴をRDF/JSON-LDで記述し、ブロックチェーンの不変性とSemantic Webの表現力を組み合わせたメタデータ管理を実践します。
RDFとJSON-LDの基礎
RDFトリプル
RDFはすべての情報を「主語(Subject)- 述語(Predicate)- 目的語(Object)」の3つ組(トリプル)で表現します。
JSON-LD
JSON-LDは、通常のJSONにセマンティックな意味を付与する仕組みです。@context で語彙の定義を行い、既存のJSON構造にリンクトデータの機能を追加します。
{
"@context": {
"dcterms": "http://purl.org/dc/terms/",
"prov": "http://www.w3.org/ns/prov#",
"schema": "https://schema.org/"
},
"@id": "https://example.org/nft/0",
"@type": "schema:VisualArtwork",
"dcterms:title": "鳥獣戯画 甲巻(部分)",
"dcterms:creator": {
"@id": "http://viaf.org/viaf/252937213"
}
}
PROV-Oオントロジー
PROV-O(The PROV Ontology)は、W3Cが標準化した来歴情報を記述するためのオントロジーです。デジタルオブジェクトの生成・変換・委譲に関する情報を、機械可読な形で記述できます。
PROV-Oの主要クラス
| クラス | 説明 | NFTでの対応 |
|---|---|---|
prov:Entity | 物理的・デジタル的なもの | NFTトークン |
prov:Activity | 何かを行う行為 | ミント、転送 |
prov:Agent | 行為の実行者 | ウォレットアドレス |
PROV-Oの主要プロパティ
| プロパティ | 説明 |
|---|---|
prov:wasGeneratedBy | Entity が Activity によって生成された |
prov:wasAttributedTo | Entity が Agent に帰属する |
prov:wasAssociatedWith | Activity が Agent に関連する |
prov:atTime | Activity の実行時刻 |
prov:used | Activity が Entity を使用した |
NFT来歴のJSON-LD記述
ブロックチェーンのトランザクション情報をPROV-Oで記述するJSON-LDドキュメントを生成するスクリプトを作成します。
// scripts/generateProvLD.ts
import { ethers } from "hardhat";
interface TransferEvent {
from: string;
to: string;
tokenId: number;
transactionHash: string;
blockNumber: number;
timestamp: number;
}
/**
* NFTの来歴をJSON-LD形式で生成する
*/
function generateProvenanceLD(
contractAddress: string,
tokenId: number,
events: TransferEvent[],
nftMetadata: {
name: string;
description: string;
image: string;
}
): object {
const nftId =
`ethereum:${contractAddress}/token/${tokenId}`;
// エージェント(ウォレットアドレス)の一覧を抽出
const agentAddresses = new Set<string>();
for (const event of events) {
if (event.from !== ethers.ZeroAddress) {
agentAddresses.add(event.from);
}
agentAddresses.add(event.to);
}
// エージェントのJSON-LD表現
const agents = Array.from(agentAddresses).map((addr) => ({
"@id": `ethereum:address:${addr}`,
"@type": "prov:Agent",
"rdfs:label": `Ethereum Account ${addr.slice(0, 8)}...`,
"schema:identifier": addr,
}));
// アクティビティ(トランザクション)のJSON-LD表現
const activities = events.map((event, index) => {
const isMint = event.from === ethers.ZeroAddress;
const activityId =
`ethereum:tx:${event.transactionHash}`;
const activity: any = {
"@id": activityId,
"@type": ["prov:Activity"],
"rdfs:label": isMint
? `NFT Minting (Token #${event.tokenId})`
: `NFT Transfer (Token #${event.tokenId})`,
"prov:atTime": new Date(
event.timestamp * 1000
).toISOString(),
"prov:wasAssociatedWith": {
"@id": `ethereum:address:${event.to}`,
},
"schema:identifier": event.transactionHash,
"custom:blockNumber": event.blockNumber,
};
if (isMint) {
activity["@type"].push("custom:MintActivity");
activity["prov:generated"] = { "@id": nftId };
} else {
activity["@type"].push("custom:TransferActivity");
activity["prov:used"] = { "@id": nftId };
activity["custom:from"] = {
"@id": `ethereum:address:${event.from}`,
};
activity["custom:to"] = {
"@id": `ethereum:address:${event.to}`,
};
}
return activity;
});
// NFT(Entity)のJSON-LD表現
const nftEntity: any = {
"@id": nftId,
"@type": ["prov:Entity", "schema:VisualArtwork"],
"rdfs:label": nftMetadata.name,
"dcterms:title": nftMetadata.name,
"dcterms:description": nftMetadata.description,
"schema:image": nftMetadata.image,
"schema:identifier": `${contractAddress}#${tokenId}`,
};
// ミントイベントがあれば wasGeneratedBy を設定
const mintEvent = events.find(
(e) => e.from === ethers.ZeroAddress
);
if (mintEvent) {
nftEntity["prov:wasGeneratedBy"] = {
"@id": `ethereum:tx:${mintEvent.transactionHash}`,
};
nftEntity["prov:wasAttributedTo"] = {
"@id": `ethereum:address:${mintEvent.to}`,
};
nftEntity["prov:generatedAtTime"] = new Date(
mintEvent.timestamp * 1000
).toISOString();
}
// 最新の所有者
const latestEvent = events[events.length - 1];
if (latestEvent) {
nftEntity["custom:currentOwner"] = {
"@id": `ethereum:address:${latestEvent.to}`,
};
}
// JSON-LDドキュメント全体
return {
"@context": {
"prov": "http://www.w3.org/ns/prov#",
"dcterms": "http://purl.org/dc/terms/",
"schema": "https://schema.org/",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"custom": "https://example.org/ontology/nft#",
"ethereum": "https://etherscan.io/",
},
"@graph": [nftEntity, ...agents, ...activities],
};
}
async function main() {
const contractAddress = process.env.CONTRACT_ADDRESS!;
const tokenId = 0;
// ブロックチェーンからイベントを取得
const contract = await ethers.getContractAt(
"CulturalNFT",
contractAddress
);
const filter = contract.filters.Transfer(null, null, tokenId);
const rawEvents = await contract.queryFilter(filter);
const events: TransferEvent[] = [];
for (const event of rawEvents) {
const block = await event.getBlock();
const args = (event as any).args;
events.push({
from: args.from,
to: args.to,
tokenId: Number(args.tokenId),
transactionHash: event.transactionHash,
blockNumber: event.blockNumber,
timestamp: block.timestamp,
});
}
// tokenURIを取得
const tokenURI = await contract.tokenURI(tokenId);
// JSON-LDの生成
const jsonld = generateProvenanceLD(
contractAddress,
tokenId,
events,
{
name: "鳥獣戯画 甲巻(部分)",
description: "国宝・鳥獣人物戯画のデジタル複製",
image: tokenURI,
}
);
console.log(JSON.stringify(jsonld, null, 2));
}
main().catch(console.error);
生成されるJSON-LDの例
{
"@context": {
"prov": "http://www.w3.org/ns/prov#",
"dcterms": "http://purl.org/dc/terms/",
"schema": "https://schema.org/",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"custom": "https://example.org/ontology/nft#",
"ethereum": "https://etherscan.io/"
},
"@graph": [
{
"@id": "ethereum:0x1234.../token/0",
"@type": ["prov:Entity", "schema:VisualArtwork"],
"rdfs:label": "鳥獣戯画 甲巻(部分)",
"dcterms:title": "鳥獣戯画 甲巻(部分)",
"dcterms:description": "国宝・鳥獣人物戯画のデジタル複製",
"prov:wasGeneratedBy": {
"@id": "ethereum:tx:0xabc123..."
},
"prov:wasAttributedTo": {
"@id": "ethereum:address:0x742d..."
},
"prov:generatedAtTime": "2024-01-15T10:30:00.000Z"
},
{
"@id": "ethereum:address:0x742d...",
"@type": "prov:Agent",
"rdfs:label": "Ethereum Account 0x742d35..."
},
{
"@id": "ethereum:tx:0xabc123...",
"@type": ["prov:Activity", "custom:MintActivity"],
"rdfs:label": "NFT Minting (Token #0)",
"prov:atTime": "2024-01-15T10:30:00.000Z",
"prov:generated": {
"@id": "ethereum:0x1234.../token/0"
}
}
]
}
NFTメタデータへのJSON-LD埋め込み
NFTの標準メタデータにJSON-LDの @context を追加することで、既存のマーケットプレイスとの互換性を保ちながらセマンティックな情報を付与できます。
{
"@context": {
"dcterms": "http://purl.org/dc/terms/",
"schema": "https://schema.org/",
"prov": "http://www.w3.org/ns/prov#"
},
"@type": "schema:VisualArtwork",
"name": "鳥獣戯画 甲巻(部分)",
"description": "国宝・鳥獣人物戯画のデジタル複製",
"image": "ipfs://QmYourImageHash",
"dcterms:creator": {
"@id": "http://viaf.org/viaf/252937213",
"schema:name": "鳥羽僧正覚猷(伝)"
},
"dcterms:spatial": {
"@id": "http://www.wikidata.org/entity/Q2072892",
"schema:name": "高山寺"
},
"dcterms:temporal": "平安時代後期",
"schema:material": "紙本墨画",
"attributes": [
{ "trait_type": "Institution", "value": "高山寺" },
{ "trait_type": "Period", "value": "平安時代後期" }
]
}
@context が存在しないJSONパーサー(OpenSea等)は @context フィールドを単に無視するため、後方互換性が保たれます。一方、JSON-LD対応のツールは dcterms:creator や dcterms:spatial のリンクをたどって関連情報を取得できます。
PythonでRDFグラフを構築する
より本格的なRDF処理には、Pythonの rdflib ライブラリが便利です。
# scripts/build_rdf_graph.py
from rdflib import Graph, Namespace, Literal, URIRef, BNode
from rdflib.namespace import RDF, RDFS, DCTERMS, XSD, PROV
from datetime import datetime
import json
# 名前空間の定義
SCHEMA = Namespace("https://schema.org/")
CUSTOM = Namespace("https://example.org/ontology/nft#")
ETH = Namespace("https://etherscan.io/")
def build_provenance_graph(
contract_address: str,
token_id: int,
events: list,
metadata: dict,
) -> Graph:
"""NFTの来歴情報をRDFグラフとして構築する"""
g = Graph()
# 名前空間のバインド
g.bind("prov", PROV)
g.bind("dcterms", DCTERMS)
g.bind("schema", SCHEMA)
g.bind("custom", CUSTOM)
g.bind("eth", ETH)
# NFT Entity
nft_uri = URIRef(
f"https://etherscan.io/{contract_address}/token/{token_id}"
)
g.add((nft_uri, RDF.type, PROV.Entity))
g.add((nft_uri, RDF.type, SCHEMA.VisualArtwork))
g.add((nft_uri, DCTERMS.title,
Literal(metadata["name"], lang="ja")))
g.add((nft_uri, DCTERMS.description,
Literal(metadata["description"], lang="ja")))
for event in events:
tx_uri = URIRef(
f"https://etherscan.io/tx/{event['transactionHash']}"
)
agent_uri = URIRef(
f"https://etherscan.io/address/{event['to']}"
)
# Activity
g.add((tx_uri, RDF.type, PROV.Activity))
g.add((tx_uri, PROV.atTime,
Literal(event["timestamp"],
datatype=XSD.dateTime)))
g.add((tx_uri, PROV.wasAssociatedWith, agent_uri))
# Agent
g.add((agent_uri, RDF.type, PROV.Agent))
g.add((agent_uri, RDFS.label,
Literal(f"Account {event['to'][:10]}...")))
is_mint = (
event["from"]
== "0x0000000000000000000000000000000000000000"
)
if is_mint:
g.add((tx_uri, RDF.type, CUSTOM.MintActivity))
g.add((nft_uri, PROV.wasGeneratedBy, tx_uri))
g.add((nft_uri, PROV.wasAttributedTo, agent_uri))
else:
g.add((tx_uri, RDF.type, CUSTOM.TransferActivity))
from_uri = URIRef(
f"https://etherscan.io/address/{event['from']}"
)
g.add((from_uri, RDF.type, PROV.Agent))
g.add((tx_uri, PROV.used, nft_uri))
return g
if __name__ == "__main__":
events = [
{
"eventType": "Mint",
"transactionHash": "0xabc123def456...",
"blockNumber": 12345678,
"timestamp": "2024-01-15T10:30:00Z",
"from": "0x" + "0" * 40,
"to": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"tokenId": 0,
},
{
"eventType": "Transfer",
"transactionHash": "0xdef789ghi012...",
"blockNumber": 12345700,
"timestamp": "2024-02-01T14:00:00Z",
"from": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"to": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72",
"tokenId": 0,
},
]
metadata = {
"name": "鳥獣戯画 甲巻(部分)",
"description": "国宝・鳥獣人物戯画のデジタル複製",
}
g = build_provenance_graph(
"0x1234567890abcdef1234567890abcdef12345678",
0,
events,
metadata,
)
# Turtle形式で出力
print("=== Turtle ===")
print(g.serialize(format="turtle"))
# JSON-LD形式で出力
print("\n=== JSON-LD ===")
print(g.serialize(format="json-ld", indent=2))
# トリプル数
print(f"\nTotal triples: {len(g)}")
# SPARQLクエリ: ミントされたNFTとその日時
print("\n=== SPARQL Query: Mint Events ===")
query = """
PREFIX prov: <http://www.w3.org/ns/prov#>
PREFIX custom: <https://example.org/ontology/nft#>
SELECT ?nft ?mintTime ?agent WHERE {
?activity a custom:MintActivity ;
prov:atTime ?mintTime ;
prov:wasAssociatedWith ?agent .
?nft prov:wasGeneratedBy ?activity .
}
"""
for row in g.query(query):
print(f" NFT: {row.nft}")
print(f" Minted at: {row.mintTime}")
print(f" By: {row.agent}")
AIPパッケージへの統合
前章で作成したBagItパッケージに、JSON-LDの来歴記述を追加する方法を示します。
# generate_aip.py への追記
def add_linked_data_to_bag(
bag_path: str,
jsonld_provenance: dict,
turtle_provenance: str,
):
"""BagItパッケージにLinked Dataファイルを追加する"""
ld_dir = os.path.join(bag_path, "data", "linked-data")
os.makedirs(ld_dir, exist_ok=True)
# JSON-LD形式の来歴情報
with open(
os.path.join(ld_dir, "provenance.jsonld"),
"w",
encoding="utf-8",
) as f:
json.dump(jsonld_provenance, f, ensure_ascii=False, indent=2)
# Turtle形式の来歴情報
with open(
os.path.join(ld_dir, "provenance.ttl"),
"w",
encoding="utf-8",
) as f:
f.write(turtle_provenance)
# BagItのマニフェストを更新
bag = bagit.Bag(bag_path)
bag.save(manifests=True)
これにより、AIPパッケージには画像・NFTメタデータ・ブロックチェーントランザクション情報に加えて、構造化された来歴記述がLinked Data形式で含まれることになります。
まとめ
本章では、Semantic Web技術を使ってNFTの来歴を構造的に記述する方法を学びました。PROV-Oオントロジーによるミント・転送イベントのRDF表現、JSON-LDによるNFTメタデータの拡張、rdflibを使ったRDFグラフの構築とSPARQLクエリを実践しました。
ブロックチェーンの不変性とSemantic Webの表現力を組み合わせることで、デジタル文化財の来歴情報を機械可読かつ信頼性の高い形で管理できます。この仕組みは、GLAM(Gallery、Library、Archive、Museum)分野における相互運用性のあるデジタル保存基盤として、今後さらに重要性を増していくでしょう。