class KulturnavBot(object): """Bot to enrich and create information on Wikidata from KulturNav info.""" EDIT_SUMMARY = 'import using #Kulturnav data' KULTURNAV_ID_P = '1248' GEONAMES_ID_P = '1566' SWE_KOMMUNKOD_P = '525' SWE_COUNTYKOD_P = '507' PLACE_P = '276' TIME_P = '585' # date DATASET_Q = None DISAMBIG_Q = '4167410' IS_A_P = '31' CATALOG_P = '972' DATASET_ID = None ENTITY_TYPE = None MAP_TAG = None COUNTRIES = [] # a list of country Q's ADMIN_UNITS = [] # a list of municipality+county Q's locations = {} # a dict of uuid to wikidata location matches current_uuid = '' # for debugging def __init__(self, dictGenerator, cache_max_age, verbose=False): """ Initialise the bot. Arguments: * generator - A generator that yields Dict objects. """ self.generator = dictGenerator self.repo = pywikibot.Site().data_repository() self.cutoff = None self.verbose = verbose self.require_wikidata = True self.cache_max_age = cache_max_age # trigger wdq query self.itemIds = helpers.fill_cache(self.KULTURNAV_ID_P, cache_max_age=cache_max_age) # set up WikidataStuff instance self.wd = WD(self.repo, self.EDIT_SUMMARY) # load lists self.COUNTRIES = wdqsLookup.wdq_to_wdqs(u'TREE[6256][][31]') self.ADMIN_UNITS = wdqsLookup.wdq_to_wdqs(u'TREE[15284][][31]') @classmethod def set_variables(cls, dataset_q=None, dataset_id=None, entity_type=None, map_tag=None, edit_summary=None): """Override any class variables. Used when command line arguments affect which type of run to do. @param dataset_q: the Q-id of the dataset @type dataset_q: str @param dataset_id: the uuid of the dataset @type dataset_id: str @param entity_type: the entity type to provide for the search API @type entity_type: str @param map_tag: the map_tag to use in the search API to find wikidata matches @type map_tag: str @param edit_summary: the edit_summary to use @type edit_summary: str """ cls.DATASET_Q = dataset_q or cls.DATASET_Q cls.DATASET_ID = dataset_id or cls.DATASET_ID cls.ENTITY_TYPE = entity_type or cls.ENTITY_TYPE cls.MAP_TAG = map_tag or cls.MAP_TAG cls.EDIT_SUMMARY = edit_summary or cls.EDIT_SUMMARY def run(self): """Start the robot.""" raise NotImplementedError("run() is not implemented in the base bot.") def runLayout(self, datasetRules, datasetProtoclaims, datasetSanityTest, label, shuffle): """ Execute the basic layout of a run. It should be called for a dataset-specific run which sets the parameters. param datasetRules: a dict of additional Rules or values to look for param datasetProtoclaims: a function for populating protoclaims param datasetSanityTest: a function which must return true for results to be written to Wikidata param label: the key in values to be used for label/alias. set to None to skip addNames() param shuffle: whether name/label/alias is shuffled or not i.e. if name = last, first """ count = 0 for hit in self.generator: # print count, self.cutoff if self.cutoff and count >= self.cutoff: break # some type of feedback if count % 100 == 0 and count > 0: pywikibot.output('%d entries handled...' % count) # Required rules/values to search for rules = { u'identifier': None, u'modified': None, u'seeAlso': None, u'sameAs': None, u'exactMatch': None, # not expected u'wikidata': None, u'libris-id': None, u'viaf-id': None, u'getty_aat': None, u'ulan': None } rules.update(datasetRules) # put together empty dict of values then populate values = {} for k in rules.keys(): values[k] = None if not self.populateValues(values, rules, hit): # continue with next hit if problem was encounterd continue # find the matching wikidata item hitItem = self.wikidataMatch(values) self.current_uuid = values['identifier'] # @todo: self.current_protoclaims # allows these to be accessed more easily # convert values to potential claims protoclaims = datasetProtoclaims(self, values) self.make_base_protoclaims(values, protoclaims) # output info for testing if self.verbose: pywikibot.output(values) pywikibot.output(protoclaims) pywikibot.output(hitItem) # Add information if a match was found if hitItem and hitItem.exists(): # if redirect then get target instead # make sure it passes the sanityTests if not self.sanityTest(hitItem): continue if not datasetSanityTest(self, hitItem): continue # add name as label/alias if label is not None: self.addNames(values[label], hitItem, shuffle=shuffle) # get the "last modified" timestamp and construct a Reference date = helpers.iso_to_WbTime(values[u'modified']) ref = self.make_ref(date) # add each property (if new) and source it self.addProperties(protoclaims, hitItem, ref) # allow for limited runs count += 1 # done pywikibot.output(u'Handled %d entries' % count) def populateValues(self, values, rules, hit): """ Populate values and check results given a hit. Given a list of values and a kulturnav hit, populate the values and check if result is problem free. @todo: raise Error instead of using problemFree solution param values: dict with keys and every value as None param rules: a dict with keys and values either: None: the exakt key is present in hit and its value is wanted a Rule: acording to the class above param hit: a kulturnav entry return bool problemFree """ ids = {} problemFree = True for entries in hit[u'@graph']: # populate ids for viaId rules if '@id' in entries.keys(): if entries['@id'] in ids.keys(): pywikibot.output('Non-unique viaID key: \n%s\n%s' % (entries, ids[entries['@id']])) ids[entries['@id']] = entries for entries in hit[u'@graph']: # handle rules for key, rule in rules.iteritems(): val = None if rule is None: if key in entries.keys(): val = entries[key] elif isinstance(rule, Rule): val = rule.resolve(entries, ids) # test and register found value if val is not None: if values[key] is None: values[key] = val else: pywikibot.output(u'duplicate entries for %s' % key) problemFree = False # the minimum which must have been identified if values[u'identifier'] is None: raise pywikibot.Error(u'Could not isolate the identifier from the ' u'KulturNav object! JSON layout must have ' u'changed. Crashing!') # dig into sameAs/exactMatch and seeAlso KulturnavBot.set_sameas_values(values) # only look at seeAlso if we found no Wikidata link and require one if self.require_wikidata and \ (not values[u'wikidata'] and values[u'seeAlso']): values[u'seeAlso'] = helpers.listify(values[u'seeAlso']) for sa in values[u'seeAlso']: if u'wikipedia' in sa: pywikibot.output(u'Found a Wikipedia link but no ' u'Wikidata link: %s %s' % (sa, values[u'identifier'])) problemFree = False if not problemFree: pywikibot.output(u'Found an issue with %s (%s), skipping' % (values['identifier'], values['wikidata'])) return problemFree def sanityTest(self, hitItem): """ Execute generic sanitytest which should be run independent on dataset. return bool """ return self.withoutClaimTest(hitItem, self.IS_A_P, self.DISAMBIG_Q, u'disambiguation page') def withoutClaimTest(self, hitItem, P, Q, descr): """ Execute base test that an item does not contain a particular claim. param hitItem: item to check param P: the property to look for param Q: the Q claim to look for param descr: a descriptive text return bool """ P = u'P%s' % P.lstrip('P') testItem = self.wd.QtoItemPage(Q) if self.wd.has_claim(P, testItem, hitItem): pywikibot.output(u'%s is matched to %s, ' u'FIXIT' % (hitItem.title(), descr)) return False else: return True def withClaimTest(self, hitItem, P, Q, descr, orNone=True): """ Execute base test that an item contains a certain claim. param hitItem: item to check param P: the property to look for param Q: (list) of Q claim to look for param descr: a descriptive text param orNone: if complete absence of the Property is also ok return bool """ P = u'P%s' % P.lstrip('P') Q = helpers.listify(Q) testItems = [] for q in Q: testItems.append(self.wd.QtoItemPage(q)) # check claims if P in hitItem.claims.keys(): for testItem in testItems: if self.wd.has_claim(P, testItem, hitItem): return True else: pywikibot.output(u'%s is identified as something other ' u'than a %s. Check!' % (hitItem.title(), descr)) return False elif orNone: # no P claim return True @staticmethod def set_sameas_values(values): """Isolate external identifiers through sameAs and exactMatch. @param values: All extracted values @type values: dict """ # merge sameAs and exactMatch match = helpers.bundle_values( [values[u'sameAs'], values[u'exactMatch']]) or [] # dig into sameAs/exactMatch and seeAlso for sa in match: if u'wikidata' in sa: values[u'wikidata'] = sa.split('/')[-1] elif u'libris-id' in values.keys() and \ u'libris.kb.se/auth/' in sa: values[u'libris-id'] = sa.split('/')[-1] elif u'viaf-id' in values.keys() and \ u'viaf.org/viaf/' in sa: values[u'viaf-id'] = sa.split('/')[-1] elif u'getty_aat' in values.keys() and \ u'vocab.getty.edu/aat/' in sa: values[u'getty_aat'] = sa.split('/')[-1] elif u'ulan' in values.keys() and \ u'vocab.getty.edu/ulan/' in sa: values[u'ulan'] = sa.split('/')[-1] def make_base_protoclaims(self, values, protoclaims): """Construct the protoclaims common for all KulturnavBots. Adds the claim to the protoclaims dict. @param values: the values extracted using the rules @type values: dict @param protoclaims: the dict of claims to add @type protoclaims: dict """ # kulturnav protoclaim incl. qualifier protoclaims[u'P%s' % self.KULTURNAV_ID_P] = \ WD.Statement(values[u'identifier']).addQualifier( WD.Qualifier( P=self.CATALOG_P, itis=self.wd.QtoItemPage(self.DATASET_Q)), force=True) # authority control protoclaims if values.get(u'libris-id'): protoclaims[u'P906'] = WD.Statement(values[u'libris-id']) if values.get(u'viaf-id'): protoclaims[u'P214'] = WD.Statement(values[u'viaf-id']) if values.get(u'getty_aat'): protoclaims[u'P1014'] = WD.Statement(values[u'getty_aat']) if values.get(u'ulan'): protoclaims[u'P245'] = WD.Statement(values[u'ulan']) def wikidataMatch(self, values): """ Find the matching wikidata item. Checks Wikidata first, then kulturNav. return ItemPage|None the matching item """ if values[u'identifier'] in self.itemIds: hitItemTitle = u'Q%s' % \ self.itemIds.get(values[u'identifier']) if not values[u'wikidata'] and not self.require_wikidata: # i.e. uuid has been supplied manually and exists on wikidata pass elif values[u'wikidata'] != hitItemTitle: # this may be caused by either being a redirect wd = self.wd.QtoItemPage(values[u'wikidata']) wi = self.wd.QtoItemPage(hitItemTitle) if wd.isRedirectPage() and wd.getRedirectTarget() == wi: pass elif wi.isRedirectPage() and wi.getRedirectTarget() == wd: pass else: pywikibot.output(u'Identifier missmatch (skipping): ' u'%s, %s, %s' % (values[u'identifier'], values[u'wikidata'], hitItemTitle)) return None elif values[u'wikidata']: hitItemTitle = values[u'wikidata'] else: # no match found return None # create ItemPage, bypassing any redirect hitItem = self.wd.bypassRedirect(self.wd.QtoItemPage(hitItemTitle)) # in case of redirect values[u'wikidata'] = hitItem.title() return hitItem def addNames(self, names, hitItem, shuffle=False): """ Prepare a nameObj or a list of such for add_label_or_alias(). param shuffle: bool if name order is last, first then this creates a local rearranged copy """ if names: if shuffle: namelist = [] if isinstance(names, dict): s = KulturnavBot.shuffle_names(names) if s is not None: namelist.append(s) elif isinstance(names, list): for n in names: s = KulturnavBot.shuffle_names(n) if s is not None: namelist.append(s) else: pywikibot.output(u'unexpectedly formatted name' u'object: %s' % names) if namelist: self.add_label_or_alias(namelist, hitItem) else: self.add_label_or_alias(names, hitItem) def addProperties(self, protoclaims, hitItem, ref): """ Add each property (if new) and source it. param protoclaims: a dict of claims with a key: Prop number val: Statement|list of Statments param hititem: the target entity param ref: WD.Reference """ for pcprop, pcvalue in protoclaims.iteritems(): if pcvalue: if isinstance(pcvalue, list): pcvalue = set(pcvalue) # eliminate potential duplicates for val in pcvalue: # check if None or a Statement(None) if (val is not None) and (not val.isNone()): self.wd.addNewClaim(pcprop, val, hitItem, ref) # reload item so that next call is aware of changes hitItem = self.wd.QtoItemPage(hitItem.title()) hitItem.exists() elif not pcvalue.isNone(): self.wd.addNewClaim(pcprop, pcvalue, hitItem, ref) # KulturNav specific functions def dbpedia2Wikidata(self, item): """ Convert dbpedia reference to the equivalent Wikidata item, if present. param item: dict with @language, @value keys return pywikibot.ItemPage|None """ if KulturnavBot.foobar(item): return if not all(x in item.keys() for x in (u'@value', u'@language')): pywikibot.output(u'invalid dbpedia entry: %s' % item) exit(1) # any site will work, this is just an example site = pywikibot.Site(item[u'@language'], 'wikipedia') page = pywikibot.Page(site, item[u'@value']) if page.properties().get(u'wikibase_item'): qNo = page.properties()[u'wikibase_item'] return self.wd.QtoItemPage(qNo) def db_gender(self, value): """Match gender values to items. Note that this returns a Statment unlike most other functions @param value: The gender value @type value: str @return: The gender item as a statement @rtype: WD.Statement or None """ known = { u'male': u'Q6581097', u'female': u'Q6581072', u'unknown': u'somevalue' } # a special case if value not in known.keys(): pywikibot.output(u'invalid gender entry: %s' % value) return if known[value] in (u'somevalue', u'novalue'): return WD.Statement(known[value], special=True) else: return WD.Statement(self.wd.QtoItemPage(known[value])) def db_name(self, name_obj, typ, limit=75): """Check if there is an item matching the name. A wrapper for helpers.match_name() to send it the relevant part of a nameObj. @param nameObj: {'@language': 'xx', '@value': 'xxx'} @type nameObj: dict @param typ: The name type (either 'lastName' or 'firstName') @type typ: str @param limit: Number of hits before skipping (defaults to 75, ignored if onLabs) @type limit: int @return: A matching item, if any @rtype: pywikibot.ItemPage, or None """ return helpers.match_name(name_obj['@value'], typ, self.wd, limit=limit) def location2Wikidata(self, uuid): """ Get location from kulturNav uuid. Given a kulturNav uuid or url this checks if that contains a GeoNames url and, if so, connects that to a Wikidata object using the GEONAMES_ID_P property (if any). NOTE that the WDQ results may be outdated return pywikibot.ItemPage|None """ # Check if uuid if not self.is_uuid(uuid): return None # Convert url to uuid if uuid.startswith(u'http://kulturnav.org'): uuid = uuid.split('/')[-1] # Check if already stored if uuid in self.locations.keys(): if self.locations[uuid] is None: return None else: qNo = u'Q%d' % self.locations[uuid] return self.wd.QtoItemPage(qNo) # retrieve various sources # @todo: this can be more streamlined by including wdq query for geonames # in that method. Possibly sharing the same "look-up and filter" # mechanism for both. # and then using self.locations[uuid] = self.extract... (which # returns qid or None) then (after both have been processed) # checking self.locations.get(uuid) before # making an ItemPage # # @todo: change self.locations and self.ADMIN_UNITS to include Q prefix (and thus have the methods return that) geo_sources = self.get_geo_sources(uuid) kulturarvsdata = self.extract_kulturarvsdata_location(geo_sources) if kulturarvsdata: self.locations[uuid] = kulturarvsdata qNo = u'Q%d' % self.locations[uuid] return self.wd.QtoItemPage(qNo) # retrieve hit through geonames-lookup geonames = KulturnavBot.extract_geonames(geo_sources) if geonames: # store as a resolved hit, in case wdq yields nothing self.locations[uuid] = None wdqQuery = u'STRING[%s:"%s"]' % (self.GEONAMES_ID_P, geonames) wdqResult = wdqsLookup.wdq_to_wdqs(wdqQuery) if wdqResult and len(wdqResult) == 1: self.locations[uuid] = wdqResult[0] qNo = u'Q%d' % self.locations[uuid] return self.wd.QtoItemPage(qNo) # else: # go to geonames and find wikidata from there # add to self.locations[uuid] # add GEONAMES_ID_P to the identified wikidata # no (clean) hits return None def get_geo_sources(self, uuid): """Extract any geosources from a kulturNav uuid. Given a kulturNav uuid return the corresponding properties of that target which are likely to contain geosources. @param uuid: uuid to check @type uuid: str @return: the matching properties @rtyp: list of dicts """ # debugging if not self.is_uuid(uuid): return [] query_url = 'http://kulturnav.org/api/%s' json_data = json.load(urllib2.urlopen(query_url % uuid)) sources = [] if json_data.get(u'properties'): same_as = json_data.get('properties').get('entity.sameAs') if same_as: sources += same_as source_uri = json_data.get('properties') \ .get('superconcept.sourceUri') if source_uri: sources += source_uri return sources @staticmethod def extract_geonames(sources): """Return any geonames ID given a list of get_geo_sources(). @param sources: output of get_geo_sources() @type sources: list of dicts @return: geonames id @rtype: str or None """ needle = 'http://sws.geonames.org/' for s in sources: if s.get('value') and s.get('value').startswith(needle): return s.get('value').split('/')[-1] return None def extract_kulturarvsdata_location(self, sources): """Return any qids matching kulturarvsdata geo authorities. @param sources: output of get_geo_sources() @type sources: list of dicts @return: the matching qid (without Q-prefix) @rtype: str or None @raises pywikibot.Error """ needle = u'http://kulturarvsdata.se/resurser/aukt/geo/' for s in sources: if s.get('value') and s.get('value').startswith(needle): s = s.get('value').split('/')[-1] wdq_query = None if s.startswith('municipality#'): code = s.split('#')[-1] wdq_query = u'STRING[%s:"%s"]' % (self.SWE_KOMMUNKOD_P, code) elif s.startswith('county#'): code = s.split('#')[-1] wdq_query = u'STRING[%s:"%s"]' % (self.SWE_COUNTYKOD_P, code) elif s.startswith('country#'): pass # handle via geonames instead elif s.startswith('parish#'): pass # no id's in wikidata else: raise pywikibot.Error(u'Unhandled KulturarvsdataLocation ' u'prefix: %s' % s) if wdq_query: # only here if a municipality or county was found wdq_result = wdqsLookup.wdq_to_wdqs(wdq_query) if wdq_result and len(wdq_result) == 1: self.ADMIN_UNITS.append(wdq_result[0]) return wdq_result[0] return None def getLocationProperty(self, item, strict=True): """ Return appropriate location property for an item. Given an ItemPage this returns the suitable property which should be used to indicate its location. P17 - land P131 - within administrative unit P276 - place param item: pywikibot.ItemPage|None param strict: bool whether place should be returned if no land or admin_unit hit return string|None """ if item is not None: q = int(item.title()[1:]) if q in self.COUNTRIES: return u'P17' elif q in self.ADMIN_UNITS: return u'P131' elif not strict: return u'P%s' % self.PLACE_P elif self.verbose: item.exists() pywikibot.output(u'Could not set location property for: ' u'%s (%s)' % (item.title(), item.labels.get('sv'))) return None def kulturnav2Wikidata(self, uuid): """Return Wikidata entity connected to a kulturNav uid or url. Relies on the KULTURNAV_ID_P property (if any) to get the connection. NOTE that the WDQ results may be outdated @param uuid: a kulturNav uuid or url @type uuid: str @return: the matching Wikidata item page @rtype: pywikibot.ItemPage or None """ # debugging if not self.is_uuid(uuid): return None # Convert url to uuid if uuid.startswith(u'http://kulturnav.org'): uuid = uuid.split('/')[-1] if uuid in self.itemIds.keys(): qNo = u'Q%d' % self.itemIds[uuid] return self.wd.QtoItemPage(qNo) else: return None def is_uuid(self, uuid): """Test if a string really is a uuid. @param uuid: uuid to test @type uuid: str @return: whether the test passed @rtype: bool """ if not helpers.is_str(uuid): pywikibot.output(u'Not an uuid in %s: %s' % (self.current_uuid, uuid)) return False uuid = uuid.split('/')[-1] # in case of url pattern = r'[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}' \ r'\-[0-9a-f]{4}\-[0-9a-f]{12}' m = re.search(pattern, uuid) if not m or m.group(0) != uuid: pywikibot.output(u'Not an uuid in %s: %s' % (self.current_uuid, uuid)) return False return True @staticmethod def shuffle_names(name_obj): """Detect a "Last, First" string and return as "First Last". A wrapper for helpers.reorder_names() to send it the relevant part of a name_obj. @param name_obj: {'@language': 'xx', '@value': 'xxx'} @type name_obj: dict @return: the reordered name_obj or None if reorder_names failed @rtype: dict or None """ name = helpers.reorder_names(name_obj['@value']) if name is None: return None name_obj = name_obj.copy() name_obj['@value'] = name return name_obj def make_ref(self, date): """Make a correctly formatted ref object for claims. Contains 4 parts: * P248: Stated in <the kulturnav dataset> * P577: Publication date <from the document> * P854: Reference url <using the current uuid> * P813: Retrieval date <current date> P854 Should be in source_test (after retroactively fixing older references) but by being in source_notest we ensure that duplicate uuids don't source the statement twice. @param date: The "last modified" time of the document @type date: pywikibot.WbTime @return: the formated reference @rtype WD.Reference """ reference_url = 'http://kulturnav.org/%s' % self.current_uuid ref = WD.Reference( source_test=self.wd.make_simple_claim( 'P248', self.wd.QtoItemPage(self.DATASET_Q)), source_notest=[ self.wd.make_simple_claim('P577', date), self.wd.make_simple_claim('P854', reference_url), self.wd.make_simple_claim('P813', helpers.today_as_WbTime()) ]) return ref def add_label_or_alias(self, name_obj, item, case_sensitive=False): """Add a name as either a label (if none already) or an alias. Essentially a filter for the more generic method in WikidatStuff. @param name_obj: {'@language': 'xx', '@value': 'xxx'} or a list of such @type name_obj: dict or list of dict @param item: the item to which the label/alias should be added @type item: pywikibot.ItemPage @param caseSensitive: whether the comparison is case sensitive @type caseSensitive: bool """ # for a list of entries if isinstance(name_obj, list): for n in name_obj: self.add_label_or_alias(n, item, case_sensitive=case_sensitive) # reload item so that next call is aware of any changes item = self.wd.QtoItemPage(item.title()) item.exists() return # for a single entry self.wd.addLabelOrAlias(name_obj['@language'], name_obj['@value'], item, caseSensitive=case_sensitive) @staticmethod def get_kulturnav_generator(uuids, delay=0): """Generate KulturNav items from a list of uuids. @param uuids: uuids to request items for @type uuids: list of str @param delay: delay in seconds between each kulturnav request @type delay: int @yield: dict """ for uuid in uuids: time.sleep(delay) try: json_data = KulturnavBot.get_single_entry(uuid) except pywikibot.Error as e: pywikibot.output(e) else: yield json_data @classmethod def get_search_results(cls, max_hits=250, require_wikidata=True): """Make a KulturNav search for all items of a given type in a dataset. @param max_hits: the maximum number of results to request at once @type max_hits: int @param require_wikidata: whether to filter results on having a wikidata url in sameAs @type require_wikidata: bool @return: the resulting uuids @rtype: list of str """ search_url = 'http://kulturnav.org/api/search/' + \ 'entityType:%s,' % cls.ENTITY_TYPE + \ 'entity.dataset_r:%s' % cls.DATASET_ID q = None # the map_tag query # only filter on MAP_TAG if filtering on wikidata if require_wikidata: search_url += ',%s' % cls.MAP_TAG + ':%s/%d/%d' q = '*%2F%2Fwww.wikidata.org%2Fentity%2FQ*' else: search_url += '/%d/%d' # start search results = [] offset = 0 overview_page = KulturnavBot.get_single_search_results( search_url, q, offset, max_hits) while overview_page: for item in overview_page: uuid = item[u'uuid'] if not require_wikidata or \ KulturnavBot.has_wikidata_in_sameas(item, cls.MAP_TAG): results.append(uuid) # continue offset += max_hits overview_page = KulturnavBot.get_single_search_results( search_url, q, offset, max_hits) # some feedback pywikibot.output(u'Found %d matching entries in Kulturnav' % len(results)) return results @staticmethod def has_wikidata_in_sameas(item, map_tag): """Check if a wikidata url is present in the sameAs property. @param item: the search item to check @type item: dict @param map_tag: the tag to use (concepts don't use sameAs) @type map_tag: str @rtype: bool """ # The patterns used if we filter on wikidata patterns = (u'http://www.wikidata.org/entity/', u'https://www.wikidata.org/entity/') same_as = item[u'properties'][map_tag[:map_tag.rfind('_')]] for s in same_as: if s[u'value'].startswith(patterns): return True return False @staticmethod def get_single_search_results(search_url, q, offset, max_hits): """Retrieve the results from a single API search. @param search_url: basic url from whih to build search @type search_url: str @param q: the map_tag query, if any @type q: str or None @param offset: the offset in search results @type offset: int @param max_hits: the maximum number of results to request at once @type max_hits: int @return: the search result object @rtype: dict """ actual_url = '' if q is None: actual_url = search_url % (offset, max_hits) else: actual_url = search_url % (q, offset, max_hits) search_page = urllib2.urlopen(actual_url) return json.loads(search_page.read()) @staticmethod def get_single_entry(uuid): """Retrieve the data on a single kulturnav entry. Raises an pywikibot.Error if: * @graph is not a key in the json response * a non-json response is received @param uuid: the uuid for the target item @type uuid: str @return: the entry object @rtype: dict @raise: pywikibot.Error """ query_url = 'http://kulturnav.org/%s?format=application/ld%%2Bjson' item_url = query_url % uuid try: record_page = urllib2.urlopen(item_url) json_data = json.loads(record_page.read()) except ValueError as e: raise pywikibot.Error('Error loading KulturNav item at ' '%s with error %s' % (item_url, e)) if json_data.get(u'@graph'): return json_data else: raise pywikibot.Error('No @graph in KulturNav reply at ' '%s\n data: %s' % (item_url, json_data)) @classmethod def main(cls, *args): """Start the bot from the command line.""" options = cls.handle_args(args) search_results = cls.get_search_results( max_hits=options['max_hits'], require_wikidata=options['require_wikidata']) kulturnav_generator = cls.get_kulturnav_generator( search_results, delay=options['delay']) kulturnav_bot = cls(kulturnav_generator, options['cache_max_age']) kulturnav_bot.cutoff = options['cutoff'] kulturnav_bot.require_wikidata = options['require_wikidata'] kulturnav_bot.run() @classmethod def run_from_list(cls, uuids, *args): """Start the bot with a list of uuids.""" options = cls.handle_args(args) kulturnav_generator = cls.get_kulturnav_generator( uuids, delay=options['delay']) kulturnav_bot = cls(kulturnav_generator, options['cache_max_age']) kulturnav_bot.cutoff = options['cutoff'] kulturnav_bot.require_wikidata = False kulturnav_bot.run() @staticmethod def handle_args(args): """Parse and load all of the basic arguments. Also passes any needed arguments on to pywikibot and sets any defaults. @param args: arguments to be handled @type args: list of strings @return: list of options @rtype: dict """ options = { 'cutoff': None, 'max_hits': 250, 'delay': 0, 'require_wikidata': True, 'cache_max_age': 0, } for arg in pywikibot.handle_args(args): option, sep, value = arg.partition(':') if option == '-cutoff': options['cutoff'] = int(value) elif option == '-max_hits': options['max_hits'] = int(value) elif option == '-delay': options['delay'] = int(value) elif option == '-any_item': options['require_wikidata'] = False elif option == '-wdq_cache': options['cache_max_age'] = int(value) return options @staticmethod def foobar(item): """Badly named escape mechanism for list results.""" if isinstance(item, list): pywikibot.output(FOO_BAR) return True return False
class PaintingsImageBot: """Bot to enrich, and create, for items about paintings on Wikidata.""" def __init__(self, dict_generator, people_items): """Initialise the bot.""" self.people_items = people_items self.generator = dict_generator self.repo = pywikibot.Site().data_repository() self.wd = WD(self.repo, edit_summary=EDIT_SUMMARY) # Set log file out_dir = path.join(path.split(__file__)[0]) log_filename = path.join(out_dir, u'PaintingsImageBot.log') self.log = codecs.open(log_filename, 'a', 'utf-8') def run(self): """Start the robot.""" self.creators = {} for painting_data in self.generator: # isolate ids lido_data, qid, commons_file = painting_data painting_item = self.wd.QtoItemPage(qid) self.process_painting(painting_item, lido_data, commons_file) def process_painting(self, item, lido_data, commons_file): """Process a single painting.""" item.exists() # load the item obj_id_ref = self.make_obj_id_ref(lido_data.get('obj_id')) # lido_ref = self.make_lido_ref(lido_data) # make a reference object self.check_and_add_labels(item, lido_data) self.add_image_claim(item, commons_file, obj_id_ref) self.add_depicted_claim(item, lido_data, obj_id_ref) self.add_date_claim(item, lido_data, obj_id_ref) self.add_dimension_claims(item, lido_data, obj_id_ref) def add_dimension_claims(self, item, lido_data, ref): """ Add height/P2048 and width/P2049 claims. Only add non-framed measurements with just height and width. """ height_p = u'P2048' width_p = u'P2049' # diameter_p = u'P2386' # thickness_p = u'P2610' dimensions = lido_data.get('measurements').get('_') # non-framed if not dimensions or not dimensions.get('unit'): return None elif not dimensions.get('width') or not dimensions.get('height') \ or dimensions.get('depth'): # skip complicated cases for now return None elif not helpers.get_unit_q(dimensions.get('unit')): pywikibot.output(u'"%s" is an unmapped unit' % dimensions.get('unit')) return None # prepare all parts before adding claims unit = helpers.get_unit_q(dimensions.get('unit')) # unit = self.wd.QtoItemPage(unit) unit = entity_url_hack(unit) height = pywikibot.WbQuantity( dimensions.get('height'), # unit=unit, entity=unit, site=self.wd.repo) width = pywikibot.WbQuantity( dimensions.get('width'), # unit=unit, entity=unit, site=self.wd.repo) # make claims self.wd.addNewClaim(height_p, WD.Statement(height), item, ref) self.wd.addNewClaim(width_p, WD.Statement(width), item, ref) def add_date_claim(self, item, lido_data, ref): """ Add an inception/P571 claim. Only adds the claim if it's an exact year. """ prop = u'P571' creation_date = lido_data.get('creation_date') wb_date = None if not creation_date: return None # exact date if creation_date.get('earliest') and \ creation_date.get('earliest') == creation_date.get('latest'): wb_date = helpers.iso_to_WbTime(creation_date.get('earliest')) # make claim if wb_date: self.wd.addNewClaim(prop, WD.Statement(wb_date), item, ref) def add_depicted_claim(self, item, lido_data, ref): """Add a depicted/P180.""" prop = u'P180' if not lido_data.get('subjects'): return None for subject in lido_data.get('subjects'): nsid = subject.get(u'other_id') if nsid in self.people_items: person_item = self.wd.QtoItemPage(self.people_items[nsid]) self.wd.addNewClaim(prop, WD.Statement(person_item), item, ref) def add_image_claim(self, item, commons_file, ref): """ Add a image/P18 claim. Only adds it if there is None already. If one exists output to log. """ prop = u'P18' if not commons_file: return file_page = pywikibot.FilePage(pywikibot.Site('commons', 'commons'), commons_file) # check if another image is already used if prop in item.claims and \ not self.wd.has_claim(prop, file_page, item): self.log.write( u"%s already contains image claim: %s -> %s\n" % (item.title(), item.claims.get(prop)[0].getTarget().title(), file_page.title())) else: self.wd.addNewClaim(prop, WD.Statement(file_page), item, ref) def check_and_add_labels(self, item, lido_data): """Process the title field add to the item if needed.""" if not lido_data.get('title'): return for lang, value in lido_data.get('title').iteritems(): if lang == '_': continue try: self.wd.addLabelOrAlias(lang, value, item, caseSensitive=False) except pywikibot.data.api.APIError as e: self.log.write(u"%s: had an error: %s\n" % (item.title(), e)) def make_obj_id_ref(self, obj_id): """Make a reference object pointing to the objects collection page.""" uri = u'http://collection.nationalmuseum.se/eMuseumPlus?' \ u'service=ExternalInterface&module=collection&' \ u'objectId=%s&viewType=detailView' % obj_id return self.make_url_reference(uri) def make_url_reference(self, uri): """ Make a Reference object with a retrieval url and today's date. @param uri: retrieval uri/url @type uri: str @rtype: WD.Reference """ date = helpers.today_as_WbTime() ref = WD.Reference(source_test=self.wd.make_simple_claim(u'P854', uri), source_notest=self.wd.make_simple_claim( u'P813', date)) return ref # Not implemented due to uncertainty on referencing individual xml files def make_lido_ref(self, lido_data): """ Make a Reference object for the dataset. Contains 4 parts: * P248: Stated in <the Nationalmuseum dataset> * P577: Publication date <from creation date of the document> * P854: Reference url <using the input url> * P813: Retrieval date <current date> """ exit() # P248: Nationalmuseum dataset xml_file = lido_data.get('source_file') date = helpers.today_as_WbTime() pub_date = helpers.iso_to_WbTime(u'2016-09-30') zip_url = u'https://github.com/NationalmuseumSWE/WikidataCollection/' \ u'blob/master/valid_items_transform_1677.tgz' ref = WD.Reference(source_test=[ self.wd.make_simple_claim(u'P854', zip_url), self.wd.make_simple_claim(u'P577', pub_date), self.wd.make_simple_claim(u'P?', xml_file), ], source_notest=self.wd.make_simple_claim( u'P813', date)) return ref
class ImporterBot(object): """Bot to enrich/create info on Wikidata for Australian heritage items.""" def __init__(self, base_path, new=False, cutoff=None, preview_file=None): """ Initialise the ImporterBot. :param base_path: path to the output directory :param new: whether to also create new items :param cutoff: the number of items to process before stopping. None being interpreted as all. :param preview_file: run in demo mode (create previews rather than live edits) and output the result to this file. """ self.repo = pywikibot.Site().data_repository() self.wd = WdS(self.repo, EDIT_SUMMARY) self.new = new self.cutoff = cutoff if preview_file: self.demo = True self.preview_file = path.join(base_path, preview_file) else: self.demo = False self.preview_data = [] self.set_references() self.place_id_p = 'P3008' # unique identifier property self.country = self.wd.QtoItemPage('Q408') self.states = self.make_states_map() self.settlements = self.make_settlements_map() self.hectares = self.wd.QtoItemPage(helpers.get_unit_q('ha')) self.make_status_and_instance_map() self.place_id_items = helpers.fill_cache_wdqs(self.place_id_p, no_strip=True) def set_references(self): """Set the three types of references needed.""" self.ref = { 'national': self.make_url_ref( 'http://data.gov.au/dataset/2016-soe-her-aus-national-heritage', # noqa '2017-07-21', '2017-06-07'), 'commonwealth': self.make_url_ref( 'http://data.gov.au/dataset/commonwealth-heritage-list', '2017-07-21', '2017-05-31') } self.coord_ref = { 'national': self.make_url_ref( 'http://www.environment.gov.au/heritage/places/national-heritage-list', # noqa '2017-08-13'), 'commonwealth': self.make_url_ref( 'https://data.gov.au/dataset/57720684-4948-45db-a2c8-37259d531d87', # noqa '2017-08-13', '2017-07-10') } def make_status_and_instance_map(self): """Construct mapping for cultural heritage status and instance type.""" self.status = { 'national': self.wd.QtoItemPage('Q20747146'), 'commonwealth': self.wd.QtoItemPage('Q30108476') } self.instance_type = { 'indigenous': self.wd.QtoItemPage('Q38048771'), 'historic': self.wd.QtoItemPage('Q38048707'), 'natural': self.wd.QtoItemPage('Q38048753') } def make_settlements_map(self): """Retrieve Australian settlements with state/territory connection.""" sparql = ( "SELECT DISTINCT ?city ?cityLabel ?admin ?adminLabel " "WHERE " "{ " "?city wdt:P31/wdt:P279* wd:Q486972 . " "?city wdt:P17 wd:Q408 . " "?city wdt:P131* ?admin . " "{ ?admin wdt:P31 wd:Q5852411 . }" "UNION" "{ ?admin wdt:P31 wd:Q14192252 . }" "UNION" "{ ?admin wdt:P31 wd:Q14192199 . }" 'SERVICE wikibase:label { bd:serviceParam wikibase:language "en" . }' # noqa "}") data = wdqs.make_simple_wdqs_query(sparql) settlements = dict() for d in data: state_qid = d['admin'].split('/')[-1] city_qid = d['city'].split('/')[-1] city_name = d['cityLabel'] if city_name not in settlements: settlements[city_name] = [] settlements[city_name].append({ 'state': state_qid, 'qid': city_qid }) return settlements def make_states_map(self): """ Retrieve the state/territory mappings from Wikidata. Also tries to match items for the EXT and OS codes. """ sparql = ("SELECT ?item ?iso " "WHERE " "{ " "?item wdt:P300 ?value . " "?item wdt:P17 wd:Q408 . " "BIND(REPLACE(?value, 'AU-', '', 'i') AS ?iso) " "}") data = wdqs.make_select_wdqs_query(sparql, 'item', 'iso') states = dict() for k, v in data.items(): states[v] = self.wd.QtoItemPage(k) # external territories (random hits mapped) states['EXT'] = { 'Ashmore and Cartier Islands': self.wd.QtoItemPage('Q133888'), "Australian Antarctic Territory|Dumont D'Urville Station|Mawson Station": self.wd.QtoItemPage('Q178994'), # noqa 'Christmas Island|Settlement|Drumsite|Poon Saan': self.wd.QtoItemPage('Q31063'), # noqa 'Cocos (Keeling) Islands': self.wd.QtoItemPage('Q36004'), 'Coral Sea Islands': self.wd.QtoItemPage('Q172216'), 'Heard and McDonald Islands': self.wd.QtoItemPage('Q131198'), 'Jervis Bay Territory': self.wd.QtoItemPage('Q15577'), 'Norfolk Island|Kingston|Longridge|Burnt Pine|Middlegate': self.wd.QtoItemPage('Q31057') # noqa } # OS other state? states['OS'] = { 'United Kingdom': self.wd.QtoItemPage('Q145'), 'USA': self.wd.QtoItemPage('Q30') } return states def make_url_ref(self, url, fetch_date, publish_date=None): """Make a Reference object for a url. Contains 3 parts: * P813: Retrieval date * P577: Publication date <from creation date of the document> * P854: Reference url <using the input url> :param url: the source url :param fetch_date: the retrieval date url (iso) :param publish_date: the retrieval date url (iso) :return: WdS.Reference """ date_claims = [] if publish_date: date_claims.append( self.wd.make_simple_claim('P577', helpers.iso_to_WbTime(publish_date))) date_claims.append( self.wd.make_simple_claim('P813', helpers.iso_to_WbTime(fetch_date))) ref = WdS.Reference( source_test=[self.wd.make_simple_claim('P854', url)], source_notest=date_claims) return ref def output_previews(self): """Output any PreviewItems to the preview_file.""" with open(self.preview_file, 'w', encoding='utf-8') as f: for preview in self.preview_data: f.write(preview.make_preview_page()) f.write('--------------\n\n') pywikibot.output('Created "{}" for previews'.format(self.preview_file)) def process_all_objects(self, data): """ Handle all the Australian heritage objects. Only increments counter when an object is updated. :param data: dict of all the heritage objects. """ count = 0 for place_id, entry_data in data.items(): if self.cutoff and count >= self.cutoff: break item = None if place_id in self.place_id_items: item = self.wd.QtoItemPage(self.place_id_items[place_id]) if item or self.new: self.process_single_object(entry_data, item) count += 1 def process_single_object(self, data, item): """ Process a single Australian heritage object. :param data: dict of data for a single object :param item: Wikidata item associated with an object, or None if one should be created. """ if not self.demo: item = item or self.create_new_place_id_item(data) item.exists() # load the item contents # Determine claims labels = self.make_labels(data) descriptions = self.make_descriptions(data) protoclaims = self.make_protoclaims(data) ref = self.ref[self.get_heritage_type(data['type'])] # Upload claims if self.demo: self.preview_data.append( PreviewItem(labels, descriptions, protoclaims, item, ref)) else: self.commit_labels(labels, item) self.commit_descriptions(descriptions, item) self.commit_claims(protoclaims, item, ref) def create_new_place_id_item(self, data): """ Create a new place_id item with some basic info and return it. :param data: dict of data for a single object :return: pywikibot.ItemPage """ labels = helpers.convert_language_dict_to_json(self.make_labels(data), typ='labels') desc = helpers.convert_language_dict_to_json( self.make_descriptions(data), typ='descriptions') id_claim = self.wd.make_simple_claim(self.place_id_p, data.get('place_id')) item_data = { "labels": labels, "descriptions": desc, "claims": [ id_claim.toJSON(), ] } try: return self.wd.make_new_item(item_data, EDIT_SUMMARY) except pywikibot.data.api.APIError as e: raise pywikibot.Error('Error during item creation: {:s}'.format(e)) def make_labels(self, data): """ Make a label object from the available info. :param data: dict of data for a single object :return: label dict """ labels = {} name = data.get('name') if name: labels['en'] = [ name.replace(' ', ' ').strip(), ] return labels def make_descriptions(self, data): """ Make a description object in English. Address is partitioned so as to include the place name and territory/state in case these are not included anywhere later. :param data: dict of data for a single object :return: description object """ text = '{heritage_type} {list_type} heritage site in {address}' descriptions = { 'en': text.format(heritage_type=data['class'].lower(), list_type=self.get_heritage_type(data['type']), address=data['address'].rpartition(',')[2].strip()) } return descriptions def commit_labels(self, labels, item): """ Add labels and aliases to item. :param labels: label object :param item: item to add labels to """ if labels: self.wd.add_multiple_label_or_alias(labels, item, case_sensitive=False) def commit_descriptions(self, descriptions, item): """ Add descriptions to item. :param descriptions: description object :param item: item to add descriptions to """ if descriptions: self.wd.add_multiple_descriptions(descriptions, item) def commit_claims(self, protoclaims, item, default_ref): """ Add each claim (if new) and source it. :param protoclaims: a dict of claims with key: Prop number val: Statement|list of Statements :param item: the target entity :param default_ref: main/default reference to use """ for prop, statements in protoclaims.items(): if statements: statements = helpers.listify(statements) statements = set(statements) # eliminate potential duplicates for statement in statements: # check if None or a Statement(None) if (statement is not None) and (not statement.isNone()): # use internal reference if present, else the general ref = statement.ref or default_ref self.wd.addNewClaim(prop, statement, item, ref) # reload item so that next call is aware of changes item = self.wd.QtoItemPage(item.title()) item.exists() def make_protoclaims(self, data): """ Construct potential claims for an entry. :param data: dict of data for a single heritage object """ protoclaims = dict() # P17: country protoclaims['P17'] = WdS.Statement(self.country) # P1435: heritage status heritage_type = self.get_heritage_type(data.get('type')) statement = WdS.Statement(self.status[heritage_type]) if data.get('register_date'): statement.addQualifier( WdS.Qualifier('P580', self.parse_date(data.get('register_date')))) protoclaims['P1435'] = statement # P31: class protoclaims['P31'] = WdS.Statement( self.instance_type[data.get('class').lower()]) # P3008: place_id protoclaims[self.place_id_p] = WdS.Statement(data['place_id']) # P131: state protoclaims['P131'] = WdS.Statement( self.get_state(data['state'], data['address'])) # P2046: area if data.get('hectares'): protoclaims['P2046'] = WdS.Statement( pywikibot.WbQuantity(data['hectares'], unit=self.hectares, site=self.wd.repo)) # P969: address if ',' in data['address']: protoclaims['P969'] = WdS.Statement(data['address']) # P276: place protoclaims['P276'] = WdS.Statement( self.get_place(data['state'], data['address'])) # P625: coordinate if data.get('lat') and data.get('lon'): protoclaims['P625'] = self.get_coordinate_statement( data.get('lat'), data.get('lon'), heritage_type) return protoclaims def get_coordinate_statement(self, lat, lon, heritage_type): """Construct a Statement for the provided coordinates.""" statement = WdS.Statement( pywikibot.Coordinate(float(lat), float(lon), globe='earth', precision=DEFAULT_PREC)) statement.add_reference(self.coord_ref[heritage_type]) return statement def get_heritage_type(self, typ): """Determine which heritage type the object is.""" heritage_type = None if typ.startswith('Q1116950'): heritage_type = 'commonwealth' elif typ.startswith('Q781601'): heritage_type = 'national' else: pywikibot.error('Unrecognized status: {0}'.format(typ)) return heritage_type def parse_date(self, date): """Convert date in DD-MMM-YYYY format to WbTime.""" months = [ 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC' ] dd, mmm, yyyy = date.split('-') iso = '{year}-{month:02d}-{day:02d}'.format(year=yyyy, day=int(dd), month=months.index(mmm) + 1) return helpers.iso_to_WbTime(iso) def get_place(self, state, address): """ Determine which settlement the object is in. The format of address is "street, place STATE_ISO" """ place = address.rpartition(',')[2][:-len(state)].strip() state_item = self.get_state(state, address) if place in self.settlements and state_item: hits = [] for candidate in self.settlements[place]: if candidate['state'] == state_item.id: hits.append(candidate['qid']) if len(set(hits)) == 1: return self.wd.QtoItemPage(hits[0]) def get_state(self, state, address): """Determine which state/territory the object is in.""" state_item = None if state not in self.states: pywikibot.error('Unrecognized state: {0}'.format(state)) elif state == 'EXT': address = address[:-len('EXT')].strip() for key, v in self.states['EXT'].items(): if any(address.endswith(k) for k in key.split('|')): state_item = v break elif state == 'OS': for k, v in self.states['OS'].items(): if address.endswith(k): state_item = v break else: state_item = self.states[state] return state_item
class PaintingsBot: """Bot to enrich, and create, for items about paintings on Wikidata.""" def __init__(self, dict_generator, painting_id_prop, cache_max_age=0): """Initiate the bot, loading files and querying WDQ. @param dict_generator: The generator for the Europeana painting objects @type dict_generator: generator (that yields Dict objects). @param painting_id_prop: the P-id of the painting-id property @type painting_id_prop: str @param cache_max_age: Max age of local wdq cache, defaults to 0 @type cache_max_age: int """ self.generator = dict_generator self.repo = pywikibot.Site().data_repository() self.commons = pywikibot.Site(u'commons', u'commons') self.wd = WD(self.repo) self.add_new = False # If new objects should be created self.skip_miniatures = True # If (new) miniatures should be skipped # Load prefixes and find allowed collections collections = set([INSTITUTION_Q]) self.mappings = helpers.load_json_file('mappings.json', force_path=__file__) self.prefix_map = self.mappings['prefix_map'] self.bad_prefix = self.mappings['bad_prefix'] for p, k in self.prefix_map.iteritems(): if k['subcol'] is not None: collections.add(k['subcol'].strip('Q')) self.collections = list(collections) # Set log file self.log = codecs.open(u'nationalmuseumSE.log', 'a', 'utf-8') # Load creator dump file self.creator_dump = helpers.load_json_file('Oku_NM_arbetskopia.json', force_path=__file__) # hard-coded anons e.g. "unknown swedish 17th century" anons = helpers.load_json_file('anons.json', force_path=__file__) # prepare WDQ painting query query = u'CLAIM[195:%s] AND CLAIM[%s]' % \ (',195:'.join(self.collections), painting_id_prop) self.painting_ids = helpers.fill_cache(painting_id_prop, queryoverride=query, cache_max_age=cache_max_age) # prepare WDQ artist query (nat_mus_id - Q_id pairs) self.artist_ids = helpers.fill_cache('P2538', cache_max_age=cache_max_age) # add anons for a in anons: self.artist_ids[a] = ANON_Q self.painting_id_prop = 'P%s' % painting_id_prop def run(self): """Start the robot.""" self.creators = {} for painting in self.generator: # isolate ids ids = painting['object']['proxies'][0]['dcIdentifier']['def'] painting_id = ids[0].replace('Inv Nr.:', '').strip('( )') obj_id = ids[1] # Museum contains several sub-collections. Only handle mapped ones if painting_id.split(' ')[0] in self.prefix_map.keys(): self.process_painting(painting, painting_id, obj_id) elif painting_id.split(' ')[0] not in self.bad_prefix: pywikibot.output(u'Skipped due to unknown collection: %s' % painting_id) def process_painting(self, painting, painting_id, obj_id): """Process a single painting. This will also create it if self.add_new is True. @param painting: information object for the painting @type painting: dict @param painting_id: the common (older) id of the painting in the Nationalmuseum collection @type painting_id: str @param obj_id: the internal id of the painting in the Nationalmuseum database. @type obj_id: str """ uri = u'http://collection.nationalmuseum.se/eMuseumPlus?service=' \ u'ExternalInterface&module=collection&objectId=%s&viewType=' \ u'detailView' % obj_id europeana_url = u'http://europeana.eu/portal/record%s.html' % \ painting['object']['about'] painting_item = None # newclaims = [] if painting_id in self.painting_ids: painting_item = self.create_existing_painting( painting, painting_id) elif self.add_new and not (self.skip_miniatures and PaintingsBot.is_miniature(painting)): # if objection collection is allowed and # unless it is a miniature and we are skipping those painting_item = self.create_new_painting(painting, painting_id, europeana_url, uri) # add new claims if painting_item and painting_item.exists(): data = painting_item.get(force=True) claims = data.get('claims') # add natmus id claim self.add_natmus_id(painting_item, obj_id, uri) # add inventory number with collection self.add_inventory_and_collection_claim(painting_item, painting_id, painting, uri) # Instance_of if u'P31' not in claims: self.add_instanceof_claim(painting_item, painting_id, painting) # title (as claim) # commented out as the titles in Europeana are not reliable # if u'P1476' not in claims: # self.add_title_claim(painting_item, painting) # Europeana_ID self.add_europeana_claim(painting_item, painting) # Check for potential images to add, if none is present if u'P18' not in claims: self.add_image_claim(painting_item, uri) # creator through Nat_mus_database dump self.add_natmus_creators(painting_item, obj_id, uri) # creator IFF through dbpedia # if u'P170' not in claims: # self.add_dbpedia_creator(painting_item, painting) def add_title_claim(self, painting_item, painting): """Add a title/P1476 claim based on dcTitle. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param painting: information object for the painting @type painting: dict """ dc_title = painting['object']['proxies'][0]['dcTitle'] titles = [] for lang, title in dc_title.iteritems(): titles.append(pywikibot.WbMonolingualText(title[0], lang)) for title in titles: self.wd.addNewClaim(u'P1476', WD.Statement(title), painting_item, self.make_europeana_reference(painting)) def add_locatedin_claim(self, painting_item, painting_id, painting): """Add a located_in/P276 claim based on sub-collection. No longer used as sub-collection does not match actual placing. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param painting_id: the common (older) id of the painting in the Nationalmuseum collection @type painting_id: str @param painting: information object for the painting @type painting: dict """ place = self.prefix_map[painting_id.split(' ')[0]]['place'] place_item = self.wd.QtoItemPage(place) self.wd.addNewClaim(u'P276', WD.Statement(place_item), painting_item, self.make_europeana_reference(painting)) def add_dbpedia_creator(self, painting_item, painting): """Add a Creator/P170 claim through a dbpedia look-up. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param painting: information object for the painting @type painting: dict """ creator_id = None try: db_creator = painting['object']['proxies'][1]['dcCreator']['def'] if len(db_creator) == 1: # skip anything more complex than one creator db_creator = db_creator[0].strip() if db_creator.startswith('http://dbpedia.org/resource/'): if db_creator not in self.creators.keys(): self.creators[db_creator] = \ helpers.dbpedia_2_wikidata(db_creator) creator_id = self.creators[db_creator] except KeyError: return if creator_id: self.set_creator(painting_item, self.make_europeana_reference(painting), creator_q=creator_id) def add_image_claim(self, painting_item, uri): """Add a image/P18 claim if exactly one image is found on Commons. Uses the nationalmuseum.se uri to search for matches on Commons. Adds a claim only if a unique hit is found. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param uri: reference url on nationalmuseum.se @type uri: str """ images = self.file_from_external_link(uri) if len(images) > 1: # for now don't want to choose the appropriate one pywikibot.output('Found multiple matching images for %s' % painting_item) for image in images: pywikibot.output(u'\t%s' % image) elif len(images) == 1: self.wd.addNewClaim(u'P18', WD.Statement(images[0]), painting_item, self.make_commons_reference()) def add_europeana_claim(self, painting_item, painting): """Add a Europeana ID/P727 claim. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param painting: information object for the painting @type painting: dict """ europeana_prop = u'P727' europeana_id = painting['object']['about'].lstrip('/') # abort if conflicting info if europeana_prop in painting_item.claims and \ not self.wd.has_claim(europeana_prop, europeana_id, painting_item): pywikibot.output(u'%s has conflicting %s. Expected %s' % (painting_item, europeana_prop, europeana_id)) return self.wd.addNewClaim(europeana_prop, WD.Statement(europeana_id), painting_item, self.make_europeana_reference(painting)) def add_instanceof_claim(self, painting_item, painting_id, painting): """Add an instance_of/P31 claim. Instance_of is always painting or icon while working on the paintings collection. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param painting_id: the common (older) id of the painting in the Nationalmuseum collection @type painting_id: str @param painting: information object for the painting @type painting: dict """ dcformat_item = self.wd.QtoItemPage(PAINTING_Q) # painting if painting_id.split(' ')[0] == 'NMI': dcformat_item = self.wd.QtoItemPage(ICON_Q) # icon self.wd.addNewClaim(u'P31', WD.Statement(dcformat_item), painting_item, self.make_europeana_reference(painting)) @staticmethod def is_miniature(painting): """Determine if the painting is a miniature. @param painting: information object for the painting @type painting: dict @rtype bool """ for concept in painting['object']['concepts']: if concept[u'about'] == MINIATURE_URL: # pywikibot.output(u'Skipping miniature') return True return False def create_existing_painting(self, painting, painting_id): """Add base info to an existing paining. Adds the same info as would have been added had it been created with create_new_painting() @param painting: information object for the painting @type painting: dict @param painting_id: the common (older) id of the painting in the Nationalmuseum collection @type painting_id: str @return: the created painting item @rtype: pywikibot.ItemPage """ painting_item = self.wd.QtoItemPage(self.painting_ids.get(painting_id)) # check label data = painting_item.get() labels = make_labels(painting) new_labels = find_new_values(data, labels, 'labels') if new_labels: pywikibot.output('Adding label to %s' % painting_item.title()) painting_item.editLabels(new_labels) # check description descriptions = make_descriptions(painting) if descriptions: new_descr = find_new_values(data, descriptions, 'descriptions') if new_descr: pywikibot.output('Adding description to %s' % painting_item.title()) painting_item.editDescriptions(new_descr) return painting_item def create_new_painting(self, painting, painting_id, europeana_url, uri): """Create a new painting item and return it. @param painting: information object for the painting @type painting: dict @param painting_id: the common (older) id of the painting in the Nationalmuseum collection @type painting_id: str @param europeana_url: reference url for Europeana @type europeana_url: str @param uri: reference uri at nationalmuseum.se @type uri: str @return: the created painting item @rtype: pywikibot.ItemPage """ data = {'labels': {}, 'descriptions': {}} data['labels'] = make_labels(painting) data['descriptions'] = make_descriptions(painting) if not data['descriptions']: return # print data # create new empty item and request Q-number summary = u'%s: Creating new item with data from %s' % (EDIT_SUMMARY, europeana_url) painting_item = None try: painting_item = self.wd.make_new_item(data, summary) except pywikibot.data.api.APIError as e: if e.code == u'modification-failed': # disambiguate and try again for lang, content in data['descriptions'].iteritems(): disambiguation = content['value'] + u' (%s)' % painting_id data['descriptions'][lang]['value'] = disambiguation try: painting_item = self.wd.make_new_item(data, summary) except pywikibot.data.api.APIError as e: if e.code == u'modification-failed': pywikibot.output(u'modification-failed error: ' u'skipping %s' % uri) return else: raise pywikibot.Error(u'Error during item creation: ' u'%s' % e) else: raise pywikibot.Error(u'Error during item creation: %s' % e) return painting_item def add_natmus_id(self, painting_item, obj_id, uri): """Add a natmus_painting_id/P2539 claim. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param obj_id: the nationalmuseum database id @type obj_id: str @param uri: reference url on nationalmuseum.se @type uri: str """ self.wd.addNewClaim(u'P2539', WD.Statement(obj_id), painting_item, self.make_url_reference(uri)) def add_natmus_creators(self, painting_item, obj_id, uri): """Add creator/P170 claim(s) based on the database dump info. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param obj_id: the nationalmuseum database id @type obj_id: str @param uri: reference url on nationalmuseum.se @type uri: str """ if obj_id not in self.creator_dump.keys(): return # each artwork may have multiple artists, # which must all be on wikidata for artist_id in self.creator_dump[obj_id].keys(): if artist_id not in self.artist_ids.keys(): self.logger('Artist not found on wikidata: %s' % artist_id) return dump_entry = self.creator_dump[obj_id] if len(dump_entry) == 1: artist_entry = dump_entry.iteritems().next() self.add_singel_natmus_creator(painting_item, artist_entry, uri) elif len(dump_entry) == 2: # self.add_double_natmus_creator(painting_item, dump_entry, uri) # skipping until duplication issue has been solved pass else: # for now avoid any entries with more creators return def add_singel_natmus_creator(self, painting_item, artist, uri): u"""Add a simple creator/P170 claim based on the database dump info. Handles cases with only a single identified creator. Either * Known creator * Unknown/uncertain creator somehow related to a known person where creator is someone whose function is in artist_labels. For Forgery/After work by the bot needs to be aware of both parties, and both must exist on Wikidata @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param artist: the dump entry for the artist @type artist: tuple (artist_id, artist_info) @param uri: reference url on nationalmuseum.se @type uri: str """ anonymous_combos = { u'Tillskriven': 'P1773', u'Hennes ateljé': 'P1774', u'Hans ateljé': 'P1774', u'Hennes skola': 'P1780', u'Hans skola': 'P1780', u'Hennes art': 'P1777', u'Hans art': 'P1777', } artist_labels = (u'Konstnär', u'Mästare', u'Utförd av') artist_id, artist_info = artist artist_q = self.artist_ids[artist_id] if artist_info.get('OkuBeschreibungS') or \ artist_info.get('OkuValidierungS'): # this always indicates some special case which we cannot handle # for now return if artist_info.get('OkuFunktionS') and \ artist_info.get('OkuFunktionS') in artist_labels: if len(artist_info.keys()) == 1: # i.e. all other are empty self.set_creator(painting_item, self.make_url_reference(uri), creator_q=artist_q) elif artist_info.get('OkuArtS') in anonymous_combos.keys() and \ len(artist_info.keys()) == 2: # anonymous but attributed to the artist related_info = { 'P': anonymous_combos[artist_info.get('OkuArtS')], 'itis': self.wd.QtoItemPage(artist_q) } self.set_creator(painting_item, self.make_url_reference(uri), related_info=related_info) elif not artist_info.get('OkuFunktionS') and artist_id == '1': # this is the special case of a completly unknown creator self.set_creator(painting_item, self.make_url_reference(uri)) def add_double_natmus_creator(self, painting_item, artists, uri): u"""Add a comlex creator/P170 claim based on the database dump info. Handles cases with two identified creators in a relation along the lines of "Painting/Forgery by X after a work by Y" The logic is: OkuFunktionS in derived_combos -> OkuKueID = creator of original OkuFunktionS in artist_labels -> OkuKueID = creator of derivative @param artists: the dump entries for the artists @type artists: dict of {artist_id: artist_info} @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param uri: reference url on nationalmuseum.se @type uri: str """ derived_combos = { u'Kopia efter': 'P1877', u'Efter': 'P1877', u'Förfalskning efter': 'P1778', } artist_labels = (u'Konstnär', u'Utförd av') # set up targets original = None derivative = None relation = None for artist in artists.iteritems(): artist_id, artist_info = artist if artist_info.get('OkuBeschreibungS') or \ artist_info.get('OkuValidierungS'): # this indicates some special case which we cannot handle # for now return if artist_info.get('OkuFunktionS') and \ len(artist_info.keys()) == 1: # cannot deal with OkuArtS if artist_info.get('OkuFunktionS') in artist_labels: derivative = artist elif artist_info.get('OkuFunktionS') in derived_combos.keys(): original = artist relation = derived_combos[artist_info.get('OkuFunktionS')] # verify that both roles were filled if any(creator is None for creator in (original, derivative)): return # construct info and set original_q = self.artist_ids[original[0]] derivative_q = self.artist_ids[derivative[0]] related_info = {'P': relation, 'itis': self.wd.QtoItemPage(original_q)} self.set_creator(painting_item, self.make_url_reference(uri), creator_q=derivative_q, related_info=related_info) def set_creator(self, target_item, reference, creator_q=None, related_info=None): """Set a creator/P170 claim for a creator or creator combo. Allows for simple claims as well as more complex "in the manner of" etc. @param target_item: item to which claim is added @type target_item: pywikibot.ItemPage @param reference: the reference for the statment @type reference: WD.Reference @param related_info: related info as a dict with P/itis pairs @type related_info: dict @param creator_q: the Q-id of the creator @type creator_q: str """ creator_q = creator_q or ANON_Q creator_statement = WD.Statement(self.wd.QtoItemPage(creator_q)) # set any related qualifiers if related_info: creator_statement.addQualifier( WD.Qualifier(P=related_info['P'], itis=related_info['itis'])) # set claim self.wd.addNewClaim(u'P170', creator_statement, target_item, reference) def add_inventory_and_collection_claim(self, painting_item, painting_id, painting, uri): """Add an inventory_no, with qualifier, and a collection/P195 claim. This will add the collection qualifier to any matching claim missing it. @param painting_item: item to which claim is added @type painting_item: pywikibot.ItemPage @param painting_id: the common (older) id of the painting in the Nationalmuseum collection @type painting_id: str @param painting: information object for the painting @type painting: dict @param uri: reference url on nationalmuseum.se @type uri: str """ nationalmuseum_item = self.wd.QtoItemPage(INSTITUTION_Q) collection_p = u'P195' # abort if conflicting info if self.painting_id_prop in painting_item.claims and \ not self.wd.has_claim(self.painting_id_prop, painting_id, painting_item): pywikibot.output( u'%s has conflicting inv. no (%s). Expected %s' % (painting_item, self.painting_id_prop, painting_id)) return # add inventory number with collection self.wd.addNewClaim( self.painting_id_prop, WD.Statement(painting_id).addQualifier(WD.Qualifier( P=collection_p, itis=nationalmuseum_item), force=True), painting_item, self.make_url_reference(uri)) # add collection (or subcollection) subcol = self.prefix_map[painting_id.split(' ')[0]]['subcol'] collection_item = nationalmuseum_item if subcol is not None: collection_item = self.wd.QtoItemPage(subcol) self.wd.addNewClaim(collection_p, WD.Statement(collection_item), painting_item, self.make_europeana_reference(painting)) def make_europeana_reference(self, painting): """Make a Reference object with a Europeana retrieval url and today's date. @param uri: retrieval uri/url @type uri: str @rtype: WD.Reference """ europeana_url = u'http://europeana.eu/portal/record%s.html' % \ painting['object']['about'] return self.make_url_reference(europeana_url) def make_url_reference(self, uri): """Make a Reference object with a retrieval url and today's date. @param uri: retrieval uri/url @type uri: str @rtype: WD.Reference """ date = helpers.today_as_WbTime() ref = WD.Reference(source_test=self.wd.make_simple_claim(u'P854', uri), source_notest=self.wd.make_simple_claim( u'P813', date)) return ref def make_commons_reference(self): """Make a Reference object saying imported from Wikimedia Commons.""" commons_item = self.wd.QtoItemPage(COMMONS_Q) ref = WD.Reference(source_test=self.wd.make_simple_claim( u'P143', commons_item)) # imported from return ref def file_from_external_link(self, uri): """Identify files from a Nationalmuseum uri. Hits are any files containing a link to the eMuseumPlus uri. @param uri: reference url on nationalmuseum.se @type uri: str @return: matching images @rtype: list """ images = [] uri = uri.split('://')[1] objgen = pagegenerators.LinksearchPageGenerator(uri, namespaces=[6], site=self.commons) for page in objgen: images.append(pywikibot.FilePage(self.commons, page.title())) # I have no clue how the above results in duplicates, but it does so... images = list(set(images)) return images def most_missed_creators(self, cache_max_age=0): """Produce list of most frequent, but unlinked, creators. Query WDQ for all objects in the collection missing an artist then put together a top-list for most desired creator """ expected_items = [] query = u'CLAIM[195:%s] AND NOCLAIM[170]' % \ ',195:'.join(self.collections) # collection wd_queryset = wdquery.QuerySet(query) wd_query = wdquery.WikidataQuery(cacheMaxAge=cache_max_age) data = wd_query.query(wd_queryset) if data.get('status').get('error') == 'OK': expected_items = data.get('items') creator_dict = {} counter = 0 for q_val in expected_items: q_item = self.wd.QtoItemPage(q_val) data = q_item.get() claims = data.get('claims') if u'P170' in claims: continue descr = data.get('descriptions').get('en') if descr and descr.startswith(u'painting by '): creator = descr[len(u'painting by '):] if '(' in creator: # to get rid of disambiguation addition creator = creator[:creator.find('(')].strip() if creator in creator_dict.keys(): creator_dict[creator] += 1 else: creator_dict[creator] = 1 counter += 1 pywikibot.output(u'Found %d mentions of %d creators' % (counter, len(creator_dict))) # output f = codecs.open(u'creatorHitlist.csv', 'w', 'utf-8') for k, v in creator_dict.iteritems(): f.write(u'%d|%s\n' % (v, k)) f.close() def logger(self, text): """Append text to logfile. @param text: text to output @type text: str """ self.log.write(u'%s\n' % text) self.log.flush() # because shit tends to crash