diff --git a/links-api-demo.html b/links-api-demo.html new file mode 100644 index 0000000..520a167 --- /dev/null +++ b/links-api-demo.html @@ -0,0 +1,525 @@ + + + + + + Wikidata Links API Demo + + + +
+

πŸ”— Wikidata Links API Demo

+ + +
+

Outgoing Links

+

Get all relationships going out from an entity.

+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+

Linked Entities

+

Get entities linked through a specific property.

+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+

Relationships Between Entities

+

Find all relationships between two specific entities.

+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+

Knowledge Graph

+

Build a knowledge graph starting from an entity.

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+
+ + + + \ No newline at end of file diff --git a/links-api-test.mjs b/links-api-test.mjs new file mode 100755 index 0000000..c8d406f --- /dev/null +++ b/links-api-test.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node + +// Links API Test Suite +// Tests the new Wikidata Links API functionality +// Run with: bun links-api-test.mjs + +import { linksApi, WikidataLinksAPI, client } from './wikidata-api.js'; + +/** + * Links API Test Suite + * Comprehensive tests for the new Wikidata Links API functionality + */ +class LinksAPITestSuite { + constructor() { + this.linksApi = linksApi; + this.testResults = []; + this.totalTests = 0; + this.passedTests = 0; + } + + /** + * Log test results + */ + logTest(testName, passed, message = '') { + this.totalTests++; + if (passed) { + this.passedTests++; + console.log(`βœ… ${testName}`); + } else { + console.log(`❌ ${testName}: ${message}`); + } + this.testResults.push({ testName, passed, message }); + } + + /** + * Test basic outgoing links functionality + */ + async testOutgoingLinks() { + console.log('\nπŸ”— Testing Outgoing Links...'); + + try { + // Test with Douglas Adams (Q42) - well-known entity with many links + const result = await this.linksApi.getOutgoingLinks('Q42', 'en', { limit: 5 }); + + this.logTest('Outgoing links returns result object', result && typeof result === 'object'); + this.logTest('Result has entity field', result.entity === 'Q42'); + this.logTest('Result has links array', Array.isArray(result.links)); + this.logTest('Result has total count', typeof result.total === 'number'); + this.logTest('Links are limited to 5', result.links.length <= 5); + + if (result.links.length > 0) { + const firstLink = result.links[0]; + this.logTest('Link has required fields', + firstLink.subject && firstLink.property && firstLink.hasOwnProperty('value')); + this.logTest('Link has value type', firstLink.valueType !== undefined); + + console.log(` Sample link: ${firstLink.subject} β†’ ${firstLink.property} β†’ ${firstLink.value} (${firstLink.valueType})`); + } + + } catch (error) { + this.logTest('Outgoing links basic test', false, error.message); + } + } + + /** + * Test outgoing links with property filter + */ + async testOutgoingLinksWithFilter() { + console.log('\nπŸ” Testing Outgoing Links with Property Filter...'); + + try { + // Test filtering for specific properties (occupation, instance of) + const result = await this.linksApi.getOutgoingLinks('Q42', 'en', { + propertyFilter: ['P31', 'P106'], // instance of, occupation + includeLabels: true + }); + + this.logTest('Filtered links returns results', result && result.links); + + // Check that all returned links match the filter + const allLinksMatchFilter = result.links.every(link => + ['P31', 'P106'].includes(link.property) + ); + this.logTest('All links match property filter', allLinksMatchFilter); + + // Check for labels + if (result.links.length > 0) { + const hasLabels = result.links.some(link => link.propertyLabel); + this.logTest('Links include property labels', hasLabels); + + console.log(' Filtered links:'); + result.links.forEach(link => { + console.log(` ${link.propertyLabel || link.property}: ${link.valueLabel || link.value}`); + }); + } + + } catch (error) { + this.logTest('Outgoing links with filter test', false, error.message); + } + } + + /** + * Test linked entities functionality + */ + async testLinkedEntities() { + console.log('\nπŸ‘₯ Testing Linked Entities...'); + + try { + // Get entities linked to Douglas Adams via "occupation" property + const result = await this.linksApi.getLinkedEntities('Q42', 'P106', 'en'); + + this.logTest('Linked entities returns array', Array.isArray(result)); + + if (result.length > 0) { + const firstEntity = result[0]; + this.logTest('Linked entity has required fields', + firstEntity.id && firstEntity.property === 'P106'); + + console.log(' Linked entities (occupations):'); + result.forEach(entity => { + console.log(` ${entity.label || entity.id} (${entity.id})`); + }); + } + + } catch (error) { + this.logTest('Linked entities test', false, error.message); + } + } + + /** + * Test relationships between entities + */ + async testRelationshipsBetween() { + console.log('\nπŸ”— Testing Relationships Between Entities...'); + + try { + // Test relationships between Douglas Adams (Q42) and The Hitchhiker's Guide (Q3107329) + const result = await this.linksApi.getRelationshipsBetween('Q42', 'Q3107329', 'en'); + + this.logTest('Relationships returns result object', result && typeof result === 'object'); + this.logTest('Result has entity1 and entity2 fields', + result.entity1 === 'Q42' && result.entity2 === 'Q3107329'); + this.logTest('Result has relationship arrays', + Array.isArray(result.entity1ToEntity2) && Array.isArray(result.entity2ToEntity1)); + this.logTest('Result has total count', typeof result.totalRelationships === 'number'); + + console.log(` Total relationships found: ${result.totalRelationships}`); + console.log(` Bidirectional: ${result.bidirectional}`); + + if (result.entity1ToEntity2.length > 0) { + console.log(' Q42 β†’ Q3107329:'); + result.entity1ToEntity2.forEach(rel => { + console.log(` via ${rel.propertyLabel || rel.property}`); + }); + } + + } catch (error) { + this.logTest('Relationships between entities test', false, error.message); + } + } + + /** + * Test knowledge graph functionality + */ + async testKnowledgeGraph() { + console.log('\nπŸ•ΈοΈ Testing Knowledge Graph...'); + + try { + // Build a small knowledge graph around Douglas Adams + const result = await this.linksApi.getKnowledgeGraph('Q42', 1, 'en', { + maxNodes: 10, + propertyFilter: ['P31', 'P106', 'P800'] // instance of, occupation, notable work + }); + + this.logTest('Knowledge graph returns result object', result && typeof result === 'object'); + this.logTest('Result has start entity', result.startEntity === 'Q42'); + this.logTest('Result has nodes array', Array.isArray(result.nodes)); + this.logTest('Result has edges array', Array.isArray(result.edges)); + this.logTest('Result has counts', + typeof result.totalNodes === 'number' && typeof result.totalEdges === 'number'); + + console.log(` Knowledge graph: ${result.totalNodes} nodes, ${result.totalEdges} edges`); + console.log(` Max depth: ${result.maxDepth}`); + + if (result.nodes.length > 0) { + console.log(' Sample nodes:'); + result.nodes.slice(0, 3).forEach(node => { + console.log(` ${node.label || node.id} (depth: ${node.depth})`); + }); + } + + if (result.edges.length > 0) { + console.log(' Sample edges:'); + result.edges.slice(0, 3).forEach(edge => { + console.log(` ${edge.source} β†’ ${edge.propertyLabel || edge.property} β†’ ${edge.target}`); + }); + } + + } catch (error) { + this.logTest('Knowledge graph test', false, error.message); + } + } + + /** + * Test incoming links functionality (note: limited implementation) + */ + async testIncomingLinks() { + console.log('\n⬅️ Testing Incoming Links...'); + + try { + const result = await this.linksApi.getIncomingLinks('Q42', 'en'); + + this.logTest('Incoming links returns result object', result && typeof result === 'object'); + this.logTest('Result has entity field', result.entity === 'Q42'); + this.logTest('Result includes implementation note', result.note); + + console.log(` Note: ${result.note}`); + + } catch (error) { + this.logTest('Incoming links test', false, error.message); + } + } + + /** + * Test error handling + */ + async testErrorHandling() { + console.log('\n⚠️ Testing Error Handling...'); + + try { + // Test with non-existent entity + const result = await this.linksApi.getOutgoingLinks('Q999999999', 'en'); + this.logTest('Non-existent entity handled gracefully', + result && result.links && result.links.length === 0); + + } catch (error) { + this.logTest('Error handling for non-existent entity', false, error.message); + } + + try { + // Test with invalid parameters + const result = await this.linksApi.getLinkedEntities('Q42', 'InvalidProperty', 'en'); + this.logTest('Invalid property handled gracefully', + Array.isArray(result) && result.length === 0); + + } catch (error) { + this.logTest('Error handling for invalid property', false, error.message); + } + } + + /** + * Test performance with larger datasets + */ + async testPerformance() { + console.log('\n⚑ Testing Performance...'); + + try { + const startTime = Date.now(); + + // Test with a well-connected entity and reasonable limits + const result = await this.linksApi.getOutgoingLinks('Q42', 'en', { + limit: 20, + includeLabels: true + }); + + const endTime = Date.now(); + const duration = endTime - startTime; + + this.logTest('Performance test completed', result && result.links); + this.logTest('Performance under 5 seconds', duration < 5000); + + console.log(` Retrieved ${result.links.length} links in ${duration}ms`); + + } catch (error) { + this.logTest('Performance test', false, error.message); + } + } + + /** + * Run all tests + */ + async runAllTests() { + console.log('πŸ§ͺ Starting Links API Test Suite...\n'); + + await this.testOutgoingLinks(); + await this.testOutgoingLinksWithFilter(); + await this.testLinkedEntities(); + await this.testRelationshipsBetween(); + await this.testKnowledgeGraph(); + await this.testIncomingLinks(); + await this.testErrorHandling(); + await this.testPerformance(); + + this.printSummary(); + } + + /** + * Print test summary + */ + printSummary() { + console.log('\nπŸ“Š Test Summary'); + console.log('='.repeat(50)); + console.log(`Total tests: ${this.totalTests}`); + console.log(`Passed: ${this.passedTests}`); + console.log(`Failed: ${this.totalTests - this.passedTests}`); + console.log(`Success rate: ${((this.passedTests / this.totalTests) * 100).toFixed(1)}%`); + + if (this.passedTests === this.totalTests) { + console.log('\nπŸŽ‰ All tests passed! Links API is working correctly.'); + } else { + console.log('\n⚠️ Some tests failed. Check the output above for details.'); + } + + // Show failed tests + const failedTests = this.testResults.filter(t => !t.passed); + if (failedTests.length > 0) { + console.log('\n❌ Failed tests:'); + failedTests.forEach(test => { + console.log(` - ${test.testName}: ${test.message}`); + }); + } + } +} + +// Run the test suite +async function main() { + const testSuite = new LinksAPITestSuite(); + await testSuite.runAllTests(); +} + +// Execute if run directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(error => { + console.error('Test suite failed:', error); + process.exit(1); + }); +} + +export { LinksAPITestSuite }; \ No newline at end of file diff --git a/wikidata-api.js b/wikidata-api.js index 8d0fb24..7576442 100644 --- a/wikidata-api.js +++ b/wikidata-api.js @@ -742,6 +742,360 @@ class WikidataLabelManager { } } +/** + * Wikidata Links API + * Provides direct access to Wikidata relationships and links between entities + * Enables programmatic knowledge graph traversal and relationship queries + */ +class WikidataLinksAPI { + constructor(apiClient, cacheManager, dataProcessor) { + this.apiClient = apiClient; + this.cacheManager = cacheManager; + this.dataProcessor = dataProcessor; + } + + /** + * Get all outgoing links from an entity + * @param {string} entityId - Source entity ID (e.g., 'Q42') + * @param {string} languages - Languages to fetch labels for + * @param {Object} options - Options for filtering and formatting + * @returns {Promise} - Object containing all outgoing links + */ + async getOutgoingLinks(entityId, languages = 'en', options = {}) { + const { + includeLabels = true, + propertyFilter = null, // Array of property IDs to filter by + limit = null, + includeValues = true + } = options; + + try { + // Fetch the entity with claims + const entity = await this.apiClient.fetchEntity(entityId, languages); + if (!entity || !entity.claims) { + return { entity: entityId, links: [], total: 0 }; + } + + const links = []; + const claims = entity.claims; + + // Process each property and its claims + for (const [propertyId, claimsArray] of Object.entries(claims)) { + // Apply property filter if specified + if (propertyFilter && !propertyFilter.includes(propertyId)) { + continue; + } + + if (!Array.isArray(claimsArray)) continue; + + for (const claim of claimsArray) { + const link = { + subject: entityId, + property: propertyId, + value: null, + valueType: null, + datatype: claim.mainsnak?.datatype || 'unknown' + }; + + // Extract the value based on datatype + if (claim.mainsnak?.datavalue) { + const value = claim.mainsnak.datavalue.value; + + if (claim.mainsnak.datatype === 'wikibase-item' && value.id) { + link.value = value.id; + link.valueType = 'entity'; + } else if (claim.mainsnak.datatype === 'time') { + link.value = value.time; + link.valueType = 'time'; + } else if (claim.mainsnak.datatype === 'quantity') { + link.value = value.amount; + link.valueType = 'quantity'; + } else if (claim.mainsnak.datatype === 'string') { + link.value = value; + link.valueType = 'string'; + } else if (claim.mainsnak.datatype === 'url') { + link.value = value; + link.valueType = 'url'; + } else if (claim.mainsnak.datatype === 'external-id') { + link.value = value; + link.valueType = 'external-id'; + } else if (claim.mainsnak.datatype === 'commonsMedia') { + link.value = value; + link.valueType = 'media'; + } else if (claim.mainsnak.datatype === 'geo-coordinate') { + link.value = `${value.latitude}, ${value.longitude}`; + link.valueType = 'coordinate'; + } else if (claim.mainsnak.datatype === 'monolingualtext') { + link.value = value.text; + link.valueType = 'text'; + } else { + link.value = JSON.stringify(value); + link.valueType = 'other'; + } + } + + if (includeValues || link.valueType === 'entity') { + links.push(link); + } + } + } + + // Apply limit if specified + const finalLinks = limit ? links.slice(0, limit) : links; + + // Add labels if requested + if (includeLabels) { + const allIds = new Set([entityId]); + finalLinks.forEach(link => { + allIds.add(link.property); + if (link.valueType === 'entity') { + allIds.add(link.value); + } + }); + + const labelsData = await this.apiClient.fetchLabels(Array.from(allIds), languages); + + // Add labels to the result + finalLinks.forEach(link => { + if (labelsData[link.property]?.labels) { + link.propertyLabel = this.dataProcessor.getLabel( + labelsData[link.property].labels, + languages.split('|')[0], + link.property + ); + } + if (link.valueType === 'entity' && labelsData[link.value]?.labels) { + link.valueLabel = this.dataProcessor.getLabel( + labelsData[link.value].labels, + languages.split('|')[0], + link.value + ); + } + }); + } + + return { + entity: entityId, + links: finalLinks, + total: finalLinks.length + }; + + } catch (error) { + console.error('Error getting outgoing links:', error); + throw new Error(`Failed to get outgoing links for ${entityId}: ${error.message}`); + } + } + + /** + * Get entities linked to a specific entity through a specific property + * @param {string} entityId - Source entity ID + * @param {string} propertyId - Property ID to follow + * @param {string} languages - Languages to fetch labels for + * @param {Object} options - Options for filtering + * @returns {Promise} - Array of linked entities + */ + async getLinkedEntities(entityId, propertyId, languages = 'en', options = {}) { + const { includeLabels = true, limit = null } = options; + + const links = await this.getOutgoingLinks(entityId, languages, { + includeLabels, + propertyFilter: [propertyId], + limit, + includeValues: false // Only interested in entity links + }); + + return links.links + .filter(link => link.valueType === 'entity') + .map(link => ({ + id: link.value, + label: link.valueLabel || link.value, + property: link.property, + propertyLabel: link.propertyLabel || link.property + })); + } + + /** + * Find incoming links to an entity (reverse relationships) + * Note: This requires a different API approach as Wikidata doesn't provide direct reverse lookup + * @param {string} entityId - Target entity ID + * @param {string} languages - Languages to fetch labels for + * @param {Object} options - Options for the search + * @returns {Promise} - Object containing incoming links information + */ + async getIncomingLinks(entityId, languages = 'en', options = {}) { + const { limit = 50, includeLabels = true } = options; + + try { + // Use SPARQL-like approach through Wikidata API + // This is a simplified version - in practice might need SPARQL endpoint + const url = this.apiClient.buildApiUrl({ + action: 'query', + list: 'backlinks', + bltitle: entityId, + bllimit: limit, + blnamespace: 0, + format: 'json' + }); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + // Note: This is a simplified implementation + // A complete implementation would require SPARQL queries + return { + entity: entityId, + incomingLinks: [], + total: 0, + note: 'Incoming links require SPARQL endpoint for full implementation' + }; + + } catch (error) { + console.error('Error getting incoming links:', error); + throw new Error(`Failed to get incoming links for ${entityId}: ${error.message}`); + } + } + + /** + * Get all relationships between two entities + * @param {string} entity1Id - First entity ID + * @param {string} entity2Id - Second entity ID + * @param {string} languages - Languages to fetch labels for + * @returns {Promise} - Object containing relationships between entities + */ + async getRelationshipsBetween(entity1Id, entity2Id, languages = 'en') { + try { + // Get outgoing links from entity1 to see if entity2 is linked + const entity1Links = await this.getOutgoingLinks(entity1Id, languages, { + includeLabels: true, + includeValues: false + }); + + // Get outgoing links from entity2 to see if entity1 is linked + const entity2Links = await this.getOutgoingLinks(entity2Id, languages, { + includeLabels: true, + includeValues: false + }); + + // Find relationships where entity1 -> entity2 + const entity1ToEntity2 = entity1Links.links.filter( + link => link.valueType === 'entity' && link.value === entity2Id + ); + + // Find relationships where entity2 -> entity1 + const entity2ToEntity1 = entity2Links.links.filter( + link => link.valueType === 'entity' && link.value === entity1Id + ); + + return { + entity1: entity1Id, + entity2: entity2Id, + entity1ToEntity2: entity1ToEntity2, + entity2ToEntity1: entity2ToEntity1, + totalRelationships: entity1ToEntity2.length + entity2ToEntity1.length, + bidirectional: entity1ToEntity2.length > 0 && entity2ToEntity1.length > 0 + }; + + } catch (error) { + console.error('Error getting relationships between entities:', error); + throw new Error(`Failed to get relationships between ${entity1Id} and ${entity2Id}: ${error.message}`); + } + } + + /** + * Get a knowledge graph starting from an entity with specified depth + * @param {string} startEntityId - Starting entity ID + * @param {number} depth - Maximum depth to traverse (default: 1) + * @param {string} languages - Languages to fetch labels for + * @param {Object} options - Options for graph traversal + * @returns {Promise} - Knowledge graph data + */ + async getKnowledgeGraph(startEntityId, depth = 1, languages = 'en', options = {}) { + const { + maxNodes = 100, + propertyFilter = null, + includeLabels = true + } = options; + + const nodes = new Map(); + const edges = []; + const visited = new Set(); + const queue = [{ id: startEntityId, currentDepth: 0 }]; + + try { + while (queue.length > 0 && nodes.size < maxNodes) { + const { id, currentDepth } = queue.shift(); + + if (visited.has(id) || currentDepth >= depth) { + continue; + } + + visited.add(id); + + // Get outgoing links for current entity + const links = await this.getOutgoingLinks(id, languages, { + includeLabels, + propertyFilter, + includeValues: false + }); + + // Add current entity as node + if (!nodes.has(id)) { + nodes.set(id, { + id: id, + type: 'entity', + depth: currentDepth + }); + } + + // Process links and add connected entities + for (const link of links.links) { + if (link.valueType === 'entity') { + // Add edge + edges.push({ + source: id, + target: link.value, + property: link.property, + propertyLabel: link.propertyLabel || link.property + }); + + // Add target entity to queue for next depth level + if (!visited.has(link.value) && currentDepth + 1 < depth) { + queue.push({ id: link.value, currentDepth: currentDepth + 1 }); + } + + // Add target entity as node + if (!nodes.has(link.value)) { + nodes.set(link.value, { + id: link.value, + type: 'entity', + depth: currentDepth + 1, + label: link.valueLabel || link.value + }); + } + } + } + } + + return { + startEntity: startEntityId, + nodes: Array.from(nodes.values()), + edges: edges, + totalNodes: nodes.size, + totalEdges: edges.length, + maxDepth: depth + }; + + } catch (error) { + console.error('Error building knowledge graph:', error); + throw new Error(`Failed to build knowledge graph from ${startEntityId}: ${error.message}`); + } + } +} + // Create global instances // Create instances with auto-detected cache type const apiClientInstance = new WikidataAPIClient('auto'); @@ -749,6 +1103,7 @@ const cacheManagerInstance = new WikidataCacheManager(); const dataProcessorInstance = new WikidataDataProcessor(); const labelManagerInstance = new WikidataLabelManager(apiClientInstance, cacheManagerInstance, dataProcessorInstance); const searchUtilityInstance = new WikidataSearchUtility(apiClientInstance, cacheManagerInstance, dataProcessorInstance); +const linksApiInstance = new WikidataLinksAPI(apiClientInstance, cacheManagerInstance, dataProcessorInstance); // Export classes and instances export { @@ -757,11 +1112,13 @@ export { WikidataDataProcessor, WikidataLabelManager, WikidataSearchUtility, + WikidataLinksAPI, apiClientInstance as client, cacheManagerInstance as cache, dataProcessorInstance as processor, labelManagerInstance as labelManager, - searchUtilityInstance as searchUtility + searchUtilityInstance as searchUtility, + linksApiInstance as linksApi }; // Export cache factory for custom cache configuration