2021-10-02 22:31:16 +02:00
|
|
|
import {Utils} from "../../Utils";
|
2021-10-03 01:38:57 +02:00
|
|
|
import {UIEventSource} from "../UIEventSource";
|
2022-04-22 01:45:54 +02:00
|
|
|
import * as wds from "wikidata-sdk"
|
2021-10-02 22:31:16 +02:00
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
export class WikidataResponse {
|
|
|
|
public readonly id: string
|
|
|
|
public readonly labels: Map<string, string>
|
|
|
|
public readonly descriptions: Map<string, string>
|
|
|
|
public readonly claims: Map<string, Set<string>>
|
|
|
|
public readonly wikisites: Map<string, string>
|
|
|
|
public readonly commons: string
|
2021-10-02 22:31:16 +02:00
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
constructor(
|
|
|
|
id: string,
|
|
|
|
labels: Map<string, string>,
|
|
|
|
descriptions: Map<string, string>,
|
|
|
|
claims: Map<string, Set<string>>,
|
|
|
|
wikisites: Map<string, string>,
|
|
|
|
commons: string
|
|
|
|
) {
|
2021-10-08 04:33:39 +02:00
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
this.id = id
|
|
|
|
this.labels = labels
|
|
|
|
this.descriptions = descriptions
|
|
|
|
this.claims = claims
|
|
|
|
this.wikisites = wikisites
|
|
|
|
this.commons = commons
|
2021-10-02 22:31:16 +02:00
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public static fromJson(entity: any): WikidataResponse {
|
2021-10-02 22:31:16 +02:00
|
|
|
const labels = new Map<string, string>()
|
|
|
|
for (const labelName in entity.labels) {
|
|
|
|
// The labelname is the language code
|
|
|
|
labels.set(labelName, entity.labels[labelName].value)
|
|
|
|
}
|
|
|
|
|
|
|
|
const descr = new Map<string, string>()
|
|
|
|
for (const labelName in entity.descriptions) {
|
|
|
|
// The labelname is the language code
|
|
|
|
descr.set(labelName, entity.descriptions[labelName].value)
|
|
|
|
}
|
|
|
|
|
|
|
|
const sitelinks = new Map<string, string>();
|
|
|
|
for (const labelName in entity.sitelinks) {
|
|
|
|
// labelName is `${language}wiki`
|
|
|
|
const language = labelName.substring(0, labelName.length - 4)
|
|
|
|
const title = entity.sitelinks[labelName].title
|
|
|
|
sitelinks.set(language, title)
|
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
2021-10-02 22:31:16 +02:00
|
|
|
const commons = sitelinks.get("commons")
|
|
|
|
sitelinks.delete("commons")
|
2021-10-09 22:40:52 +02:00
|
|
|
const claims = WikidataResponse.extractClaims(entity.claims);
|
|
|
|
return new WikidataResponse(
|
|
|
|
entity.id,
|
|
|
|
labels,
|
|
|
|
descr,
|
|
|
|
claims,
|
|
|
|
sitelinks,
|
|
|
|
commons
|
|
|
|
)
|
2021-10-02 22:31:16 +02:00
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static extractClaims(claimsJson: any): Map<string, Set<string>> {
|
2021-11-07 16:34:51 +01:00
|
|
|
|
|
|
|
const simplified = wds.simplify.claims(claimsJson, {
|
2021-10-18 20:40:24 +02:00
|
|
|
timeConverter: 'simple-day'
|
|
|
|
})
|
2021-11-07 16:34:51 +01:00
|
|
|
|
2021-10-02 22:31:16 +02:00
|
|
|
const claims = new Map<string, Set<string>>();
|
2021-10-18 20:40:24 +02:00
|
|
|
for (const claimId in simplified) {
|
|
|
|
const claimsList: any[] = simplified[claimId]
|
|
|
|
claims.set(claimId, new Set(claimsList));
|
2021-10-02 22:31:16 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
return claims
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class WikidataLexeme {
|
|
|
|
id: string
|
|
|
|
lemma: Map<string, string>
|
|
|
|
senses: Map<string, string>
|
|
|
|
claims: Map<string, Set<string>>
|
|
|
|
|
|
|
|
|
|
|
|
constructor(json) {
|
|
|
|
this.id = json.id
|
|
|
|
this.claims = WikidataResponse.extractClaims(json.claims)
|
|
|
|
this.lemma = new Map<string, string>()
|
|
|
|
for (const language in json.lemmas) {
|
|
|
|
this.lemma.set(language, json.lemmas[language].value)
|
|
|
|
}
|
2021-10-02 22:31:16 +02:00
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
this.senses = new Map<string, string>()
|
|
|
|
|
|
|
|
for (const sense of json.senses) {
|
|
|
|
const glosses = sense.glosses
|
|
|
|
for (const language in glosses) {
|
2021-11-07 16:34:51 +01:00
|
|
|
let previousSenses = this.senses.get(language)
|
|
|
|
if (previousSenses === undefined) {
|
2021-10-09 22:40:52 +02:00
|
|
|
previousSenses = ""
|
2021-11-07 16:34:51 +01:00
|
|
|
} else {
|
|
|
|
previousSenses = previousSenses + "; "
|
2021-10-09 22:40:52 +02:00
|
|
|
}
|
|
|
|
this.senses.set(language, previousSenses + glosses[language].value ?? "")
|
|
|
|
}
|
2021-10-02 22:31:16 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
asWikidataResponse() {
|
|
|
|
return new WikidataResponse(
|
|
|
|
this.id,
|
|
|
|
this.lemma,
|
|
|
|
this.senses,
|
|
|
|
this.claims,
|
|
|
|
new Map(),
|
|
|
|
undefined
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface WikidataSearchoptions {
|
|
|
|
lang?: "en" | string,
|
|
|
|
maxCount?: 20 | number
|
|
|
|
}
|
|
|
|
|
2022-04-22 01:45:54 +02:00
|
|
|
export interface WikidataAdvancedSearchoptions extends WikidataSearchoptions {
|
|
|
|
instanceOf?: number[];
|
|
|
|
notInstanceOf?: number[]
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2021-10-09 22:40:52 +02:00
|
|
|
/**
|
|
|
|
* Utility functions around wikidata
|
|
|
|
*/
|
|
|
|
export default class Wikidata {
|
|
|
|
|
|
|
|
private static readonly _identifierPrefixes = ["Q", "L"].map(str => str.toLowerCase())
|
2022-04-22 01:45:54 +02:00
|
|
|
private static readonly _prefixesToRemove = ["https://www.wikidata.org/wiki/Lexeme:",
|
|
|
|
"https://www.wikidata.org/wiki/",
|
|
|
|
"http://www.wikidata.org/entity/",
|
|
|
|
"Lexeme:"].map(str => str.toLowerCase())
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
private static readonly _cache = new Map<string, UIEventSource<{ success: WikidataResponse } | { error: any }>>()
|
|
|
|
|
|
|
|
public static LoadWikidataEntry(value: string | number): UIEventSource<{ success: WikidataResponse } | { error: any }> {
|
2021-10-03 01:38:57 +02:00
|
|
|
const key = this.ExtractKey(value)
|
|
|
|
const cached = Wikidata._cache.get(key)
|
2021-10-09 22:40:52 +02:00
|
|
|
if (cached !== undefined) {
|
2021-10-03 01:38:57 +02:00
|
|
|
return cached
|
|
|
|
}
|
|
|
|
const src = UIEventSource.FromPromiseWithErr(Wikidata.LoadWikidataEntryAsync(key))
|
|
|
|
Wikidata._cache.set(key, src)
|
|
|
|
return src;
|
|
|
|
}
|
2022-04-22 01:45:54 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a search text, searches for the relevant wikidata entries, excluding pages "outside of the main tree", e.g. disambiguation pages.
|
|
|
|
* Optionally, an 'instance of' can be given to limit the scope, e.g. instanceOf:5 (humans) will only search for humans
|
|
|
|
*/
|
|
|
|
public static async searchAdvanced(text: string, options: WikidataAdvancedSearchoptions): Promise<{
|
|
|
|
id: string,
|
|
|
|
relevance?: number,
|
|
|
|
label: string,
|
|
|
|
description?: string
|
|
|
|
}[]> {
|
|
|
|
let instanceOf = ""
|
|
|
|
if (options?.instanceOf !== undefined && options.instanceOf.length > 0) {
|
|
|
|
const phrases = options.instanceOf.map(q => `{ ?item wdt:P31/wdt:P279* wd:Q${q}. }`)
|
|
|
|
instanceOf = "{"+ phrases.join(" UNION ") + "}"
|
|
|
|
}
|
|
|
|
const forbidden = (options?.notInstanceOf ?? [])
|
|
|
|
.concat([17379835]) // blacklist 'wikimedia pages outside of the main knowledge tree', e.g. disambiguation pages
|
|
|
|
const minusPhrases = forbidden.map(q => `MINUS {?item wdt:P31/wdt:P279* wd:Q${q} .}`)
|
2022-04-21 12:39:28 +02:00
|
|
|
const sparql = `SELECT * WHERE {
|
|
|
|
SERVICE wikibase:mwapi {
|
|
|
|
bd:serviceParam wikibase:api "EntitySearch" .
|
2022-04-22 01:45:54 +02:00
|
|
|
bd:serviceParam wikibase:endpoint "www.wikidata.org" .
|
|
|
|
bd:serviceParam mwapi:search "${text}" .
|
|
|
|
bd:serviceParam mwapi:language "${options.lang}" .
|
|
|
|
?item wikibase:apiOutputItem mwapi:item .
|
|
|
|
?num wikibase:apiOrdinal true .
|
|
|
|
bd:serviceParam wikibase:limit ${Math.round((options.maxCount ?? 20) * 1.5) /*Some padding for disambiguation pages */} .
|
|
|
|
?label wikibase:apiOutput mwapi:label .
|
|
|
|
?description wikibase:apiOutput "@description" .
|
|
|
|
}
|
|
|
|
${instanceOf}
|
|
|
|
${minusPhrases.join("\n ")}
|
|
|
|
} ORDER BY ASC(?num) LIMIT ${options.maxCount ?? 20}`
|
2022-04-21 12:39:28 +02:00
|
|
|
const url = wds.sparqlQuery(sparql)
|
|
|
|
|
2022-04-22 01:45:54 +02:00
|
|
|
const result = await Utils.downloadJson(url)
|
|
|
|
/*The full uri of the wikidata-item*/
|
|
|
|
|
|
|
|
return result.results.bindings.map(({item, label, description, num}) => ({
|
|
|
|
relevance: num?.value,
|
|
|
|
id: item?.value,
|
|
|
|
label: label?.value,
|
|
|
|
description: description?.value
|
|
|
|
}))
|
2022-04-21 12:39:28 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
2021-10-08 04:33:39 +02:00
|
|
|
public static async search(
|
2021-10-09 22:40:52 +02:00
|
|
|
search: string,
|
|
|
|
options?: WikidataSearchoptions,
|
|
|
|
page = 1
|
|
|
|
): Promise<{
|
2021-10-08 04:33:39 +02:00
|
|
|
id: string,
|
|
|
|
label: string,
|
|
|
|
description: string
|
|
|
|
}[]> {
|
2021-10-09 22:40:52 +02:00
|
|
|
const maxCount = options?.maxCount ?? 20
|
|
|
|
let pageCount = Math.min(maxCount, 50)
|
|
|
|
const start = page * pageCount - pageCount;
|
|
|
|
const lang = (options?.lang ?? "en")
|
|
|
|
const url =
|
|
|
|
"https://www.wikidata.org/w/api.php?action=wbsearchentities&search=" +
|
|
|
|
search +
|
|
|
|
"&language=" +
|
|
|
|
lang +
|
|
|
|
"&limit=" + pageCount + "&continue=" +
|
|
|
|
start +
|
|
|
|
"&format=json&uselang=" +
|
|
|
|
lang +
|
|
|
|
"&type=item&origin=*" +
|
|
|
|
"&props=";// props= removes some unused values in the result
|
2021-11-07 02:23:28 +01:00
|
|
|
const response = await Utils.downloadJsonCached(url, 10000)
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
const result: any[] = response.search
|
|
|
|
|
|
|
|
if (result.length < pageCount) {
|
|
|
|
// No next page
|
2021-10-08 04:33:39 +02:00
|
|
|
return result;
|
2021-10-09 22:40:52 +02:00
|
|
|
}
|
|
|
|
if (result.length < maxCount) {
|
|
|
|
const newOptions = {...options}
|
|
|
|
newOptions.maxCount = maxCount - result.length
|
|
|
|
result.push(...await Wikidata.search(search,
|
|
|
|
newOptions,
|
|
|
|
page + 1
|
|
|
|
))
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
2021-10-08 04:33:39 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
2021-11-07 16:34:51 +01:00
|
|
|
|
2021-10-08 04:33:39 +02:00
|
|
|
public static async searchAndFetch(
|
2021-10-09 22:40:52 +02:00
|
|
|
search: string,
|
2022-04-22 01:45:54 +02:00
|
|
|
options?: WikidataAdvancedSearchoptions
|
2021-10-09 22:40:52 +02:00
|
|
|
): Promise<WikidataResponse[]> {
|
2021-10-08 04:33:39 +02:00
|
|
|
// We provide some padding to filter away invalid values
|
2022-04-22 01:45:54 +02:00
|
|
|
const searchResults = await Wikidata.searchAdvanced(search, options)
|
|
|
|
const maybeResponses = await Promise.all(
|
|
|
|
searchResults.map(async r => {
|
|
|
|
try {
|
|
|
|
console.log("Loading ", r.id)
|
|
|
|
return await Wikidata.LoadWikidataEntry(r.id).AsPromise()
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e)
|
|
|
|
return undefined;
|
2021-10-09 22:40:52 +02:00
|
|
|
}
|
2022-04-22 01:45:54 +02:00
|
|
|
}))
|
|
|
|
return Utils.NoNull(maybeResponses.map(r => <WikidataResponse>r["success"]))
|
2021-10-08 04:33:39 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
2022-03-14 22:57:01 +01:00
|
|
|
/**
|
|
|
|
* Gets the 'key' segment from a URL
|
2022-04-22 01:45:54 +02:00
|
|
|
*
|
2022-03-14 22:57:01 +01:00
|
|
|
* Wikidata.ExtractKey("https://www.wikidata.org/wiki/Lexeme:L614072") // => "L614072"
|
2022-04-22 10:59:03 +02:00
|
|
|
* Wikidata.ExtractKey("http://www.wikidata.org/entity/Q55008046") // => "Q55008046"
|
2022-03-14 22:57:01 +01:00
|
|
|
*/
|
2021-10-09 22:40:52 +02:00
|
|
|
public static ExtractKey(value: string | number): string {
|
2021-10-02 22:31:16 +02:00
|
|
|
if (typeof value === "number") {
|
2021-10-09 22:40:52 +02:00
|
|
|
return "Q" + value
|
2021-10-02 22:31:16 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
if (value === undefined) {
|
|
|
|
console.error("ExtractKey: value is undefined")
|
|
|
|
return undefined;
|
2021-10-02 22:31:16 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
value = value.trim().toLowerCase()
|
|
|
|
|
|
|
|
for (const prefix of Wikidata._prefixesToRemove) {
|
|
|
|
if (value.startsWith(prefix)) {
|
|
|
|
value = value.substring(prefix.length)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (value.startsWith("http") && value === "") {
|
2021-10-02 22:31:16 +02:00
|
|
|
// Probably some random link in the image field - we skip it
|
|
|
|
return undefined
|
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
for (const identifierPrefix of Wikidata._identifierPrefixes) {
|
|
|
|
if (value.startsWith(identifierPrefix)) {
|
|
|
|
const trimmed = value.substring(identifierPrefix.length);
|
2021-11-07 16:34:51 +01:00
|
|
|
if (trimmed === "") {
|
2021-10-09 22:40:52 +02:00
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
const n = Number(trimmed)
|
|
|
|
if (isNaN(n)) {
|
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
return value.toUpperCase();
|
|
|
|
}
|
2021-10-03 01:38:57 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
if (value !== "" && !isNaN(Number(value))) {
|
|
|
|
return "Q" + value
|
2021-10-03 01:38:57 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
return undefined;
|
2021-10-03 01:38:57 +02:00
|
|
|
}
|
|
|
|
|
2022-04-22 01:45:54 +02:00
|
|
|
/**
|
|
|
|
* Converts 'Q123' into 123, returns undefined if invalid
|
|
|
|
*
|
|
|
|
* Wikidata.QIdToNumber("Q123") // => 123
|
|
|
|
* Wikidata.QIdToNumber(" Q123 ") // => 123
|
|
|
|
* Wikidata.QIdToNumber(" X123 ") // => undefined
|
|
|
|
* Wikidata.QIdToNumber(" Q123X ") // => undefined
|
|
|
|
* Wikidata.QIdToNumber(undefined) // => undefined
|
|
|
|
* Wikidata.QIdToNumber(123) // => 123
|
|
|
|
*/
|
|
|
|
public static QIdToNumber(q: string | number): number | undefined {
|
|
|
|
if(q === undefined || q === null){
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if(typeof q === "number"){
|
|
|
|
return q
|
|
|
|
}
|
|
|
|
q = q.trim()
|
|
|
|
if (!q.startsWith("Q")) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
q = q.substr(1)
|
|
|
|
const n = Number(q)
|
|
|
|
if (isNaN(n)) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return n
|
|
|
|
}
|
|
|
|
|
2021-11-07 16:34:51 +01:00
|
|
|
public static IdToArticle(id: string) {
|
|
|
|
if (id.startsWith("Q")) {
|
|
|
|
return "https://wikidata.org/wiki/" + id
|
2021-10-10 23:50:50 +02:00
|
|
|
}
|
2021-11-07 16:34:51 +01:00
|
|
|
if (id.startsWith("L")) {
|
|
|
|
return "https://wikidata.org/wiki/Lexeme:" + id
|
2021-10-10 23:50:50 +02:00
|
|
|
}
|
2021-11-07 16:34:51 +01:00
|
|
|
throw "Unknown id type: " + id
|
2021-10-10 23:50:50 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
2022-08-17 02:42:59 +02:00
|
|
|
/**
|
|
|
|
* Build a SPARQL-query, return the result
|
|
|
|
*
|
|
|
|
* @param keys: how variables are named. Every key not ending with 'Label' should appear in at least one statement
|
|
|
|
* @param statements
|
|
|
|
* @constructor
|
|
|
|
*/
|
|
|
|
public static async Sparql<T>(keys: string[], statements: string[]):Promise< (T & Record<string, {type: string, value: string}>) []> {
|
|
|
|
const query = "SELECT "+keys.map(k => k.startsWith("?") ? k : "?"+k).join(" ")+"\n" +
|
|
|
|
"WHERE\n" +
|
|
|
|
"{\n" +
|
|
|
|
statements.map(stmt => stmt.endsWith(".") ? stmt : stmt+".").join("\n") +
|
|
|
|
" SERVICE wikibase:label { bd:serviceParam wikibase:language \"[AUTO_LANGUAGE]\". }\n" +
|
|
|
|
"}"
|
|
|
|
const url = wds.sparqlQuery(query)
|
|
|
|
const result = await Utils.downloadJsonCached(url, 24 * 60 * 60 * 1000)
|
|
|
|
return result.results.bindings
|
|
|
|
}
|
|
|
|
|
2021-10-03 01:38:57 +02:00
|
|
|
/**
|
|
|
|
* Loads a wikidata page
|
|
|
|
* @returns the entity of the given value
|
|
|
|
*/
|
|
|
|
public static async LoadWikidataEntryAsync(value: string | number): Promise<WikidataResponse> {
|
|
|
|
const id = Wikidata.ExtractKey(value)
|
2021-10-09 22:40:52 +02:00
|
|
|
if (id === undefined) {
|
2021-10-03 01:38:57 +02:00
|
|
|
console.warn("Could not extract a wikidata entry from", value)
|
2022-04-28 00:30:27 +02:00
|
|
|
return undefined
|
2021-10-02 22:31:16 +02:00
|
|
|
}
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
const url = "https://www.wikidata.org/wiki/Special:EntityData/" + id + ".json";
|
2021-11-07 02:23:28 +01:00
|
|
|
const entities = (await Utils.downloadJsonCached(url, 10000)).entities
|
2021-11-07 16:34:51 +01:00
|
|
|
const firstKey = <string>Array.from(Object.keys(entities))[0] // Roundabout way to fetch the entity; it might have been a redirect
|
2021-10-13 11:34:25 +02:00
|
|
|
const response = entities[firstKey]
|
2021-10-09 22:40:52 +02:00
|
|
|
|
|
|
|
if (id.startsWith("L")) {
|
|
|
|
// This is a lexeme:
|
|
|
|
return new WikidataLexeme(response).asWikidataResponse()
|
|
|
|
}
|
|
|
|
|
|
|
|
return WikidataResponse.fromJson(response)
|
2021-10-02 22:31:16 +02:00
|
|
|
}
|
|
|
|
|
2022-04-22 10:59:03 +02:00
|
|
|
}
|