| @@ -0,0 +1,348 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # | |||||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
| # of this software and associated documentation files (the "Software"), to deal | |||||
| # in the Software without restriction, including without limitation the rights | |||||
| # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
| # copies of the Software, and to permit persons to whom the Software is | |||||
| # furnished to do so, subject to the following conditions: | |||||
| # | |||||
| # The above copyright notice and this permission notice shall be included in | |||||
| # all copies or substantial portions of the Software. | |||||
| # | |||||
| # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
| # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
| # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
| # SOFTWARE. | |||||
| import difflib | |||||
| import json | |||||
| import re | |||||
| import sqlite3 | |||||
| import subprocess | |||||
| import time | |||||
| import more_itertools | |||||
| import mwparserfromhell | |||||
| import unidecode | |||||
| from earwigbot.tasks import Task | |||||
| class SynonymAuthorities(Task): | |||||
| """ | |||||
| Correct synonym authorities in taxon articles created by Qbugbot. | |||||
| """ | |||||
| name = 'synonym_authorities' | |||||
| summary = ( | |||||
| 'Fix {changes} misordered synonym authorities ' | |||||
| '([[Wikipedia:Bots/Requests for approval/EarwigBot 21|more info]])' | |||||
| ) | |||||
| def setup(self): | |||||
| self.site = self.bot.wiki.get_site() | |||||
| self.creator = 'Qbugbot' | |||||
| self.pages_path = 'qbugbot_pages.json' | |||||
| self.synonyms_path = 'qbugbot_synonyms.json' | |||||
| self.edits_path = 'qbugbot_edits.json' | |||||
| self.itis_path = 'itis.db' | |||||
| def run(self, action=None): | |||||
| if action == 'fetch_pages': | |||||
| self.fetch_pages() | |||||
| elif action == 'fetch_synonyms': | |||||
| self.fetch_synonyms() | |||||
| elif action == 'prepare_edits': | |||||
| self.prepare_edits() | |||||
| elif action == 'view_edits': | |||||
| self.view_edits() | |||||
| elif action == 'save_edits': | |||||
| self.save_edits() | |||||
| elif action is None: | |||||
| raise RuntimeError(f'This task requires an action') | |||||
| else: | |||||
| raise RuntimeError(f'No such action: {action}') | |||||
| def fetch_pages(self): | |||||
| """ | |||||
| Fetch pages edited by Qbugbot. | |||||
| """ | |||||
| pages = {} | |||||
| for chunk in more_itertools.chunked(self._iter_creations(), 500): | |||||
| pages.update(self._fetch_chunk(chunk)) | |||||
| self.logger.info(f'Fetched {len(pages)} pages') | |||||
| with open(self.pages_path, 'w') as fp: | |||||
| json.dump(pages, fp) | |||||
| def _iter_creations(self): | |||||
| params = { | |||||
| 'action': 'query', | |||||
| 'list': 'usercontribs', | |||||
| 'ucuser': self.creator, | |||||
| 'uclimit': 5000, | |||||
| 'ucnamespace': 0, | |||||
| 'ucprop': 'ids', | |||||
| 'ucshow': 'new', | |||||
| 'formatversion': 2, | |||||
| } | |||||
| results = self.site.api_query(**params) | |||||
| while contribs := results['query']['usercontribs']: | |||||
| yield from contribs | |||||
| if 'continue' not in results: | |||||
| break | |||||
| params.update(results['continue']) | |||||
| results = self.site.api_query(**params) | |||||
| def _fetch_chunk(self, chunk): | |||||
| result = self.site.api_query( | |||||
| action='query', | |||||
| prop='revisions', | |||||
| rvprop='ids|content', | |||||
| rvslots='main', | |||||
| pageids='|'.join(str(page['pageid']) for page in chunk), | |||||
| formatversion=2, | |||||
| ) | |||||
| pages = result['query']['pages'] | |||||
| assert len(pages) == len(chunk) | |||||
| return { | |||||
| page['pageid']: { | |||||
| 'title': page['title'], | |||||
| 'content': page['revisions'][0]['slots']['main']['content'], | |||||
| 'revid': page['revisions'][0]['revid'], | |||||
| } | |||||
| for page in pages | |||||
| } | |||||
| def fetch_synonyms(self): | |||||
| """ | |||||
| Fetch correct synonym lists for pages generated by fetch_pages. | |||||
| """ | |||||
| with open(self.pages_path) as fp: | |||||
| pages = json.load(fp) | |||||
| wikidata = self.bot.wiki.get_site('wikidatawiki') | |||||
| itis_property = 'P815' | |||||
| conn = sqlite3.connect(self.itis_path) | |||||
| cur = conn.cursor() | |||||
| synonyms = {} | |||||
| for chunk in more_itertools.chunked(pages.items(), 50): | |||||
| titles = {page['title']: pageid for pageid, page in chunk} | |||||
| result = wikidata.api_query( | |||||
| action='wbgetentities', | |||||
| sites='enwiki', | |||||
| titles='|'.join(titles), | |||||
| props='claims|sitelinks', | |||||
| languages='en', | |||||
| sitefilter='enwiki', | |||||
| ) | |||||
| for item in result['entities'].values(): | |||||
| if 'sitelinks' not in item: | |||||
| self.logger.warning(f'No sitelinks for item: {item}') | |||||
| continue | |||||
| title = item['sitelinks']['enwiki']['title'] | |||||
| pageid = titles[title] | |||||
| if itis_property not in item['claims']: | |||||
| self.logger.warning(f'No ITIS ID for [[{title}]]') | |||||
| continue | |||||
| claims = item['claims'][itis_property] | |||||
| assert len(claims) == 1, (title, claims) | |||||
| itis_id = claims[0]['mainsnak']['datavalue']['value'] | |||||
| cur.execute(""" | |||||
| SELECT synonym.complete_name, authors.taxon_author | |||||
| FROM synonym_links sl | |||||
| INNER JOIN taxonomic_units accepted ON sl.tsn_accepted = accepted.tsn | |||||
| INNER JOIN taxonomic_units synonym ON sl.tsn = synonym.tsn | |||||
| LEFT JOIN taxon_authors_lkp authors ON synonym.taxon_author_id = authors.taxon_author_id | |||||
| WHERE sl.tsn_accepted = ? | |||||
| UNION ALL | |||||
| SELECT complete_name, taxon_author | |||||
| FROM taxonomic_units accepted | |||||
| LEFT JOIN taxon_authors_lkp authors USING (taxon_author_id) | |||||
| WHERE accepted.tsn = ?; | |||||
| """, (itis_id, itis_id)) | |||||
| synonyms[pageid] = cur.fetchall() | |||||
| self.logger.info(f'Fetched {len(synonyms)} synonym lists') | |||||
| with open(self.synonyms_path, 'w') as fp: | |||||
| json.dump(synonyms, fp) | |||||
| def prepare_edits(self): | |||||
| """ | |||||
| Prepare edits based on the output of fetch_pages and fetch_synonyms. | |||||
| """ | |||||
| with open(self.pages_path) as fp: | |||||
| pages = json.load(fp) | |||||
| with open(self.synonyms_path) as fp: | |||||
| synonyms = json.load(fp) | |||||
| edits = {} | |||||
| for pageid, pageinfo in pages.items(): | |||||
| if pageid not in synonyms: | |||||
| continue | |||||
| wikitext = mwparserfromhell.parse(pageinfo['content']) | |||||
| try: | |||||
| changes = self._update_synonyms(pageinfo['title'], wikitext, synonyms[pageid]) | |||||
| if not changes: | |||||
| continue | |||||
| except Exception: | |||||
| self.logger.error(f'Failed to update synonyms for [[{pageinfo["title"]}]]') | |||||
| raise | |||||
| edits[pageid] = { | |||||
| 'title': pageinfo['title'], | |||||
| 'revid': pageinfo['revid'], | |||||
| 'original': pageinfo['content'], | |||||
| 'content': str(wikitext), | |||||
| 'changes': changes, | |||||
| } | |||||
| with open(self.edits_path, 'w') as fp: | |||||
| json.dump(edits, fp) | |||||
| def _update_synonyms(self, title, wikitext, synonyms): | |||||
| if len(synonyms) <= 1: | |||||
| return False | |||||
| if wikitext.split('\n', 1)[0].upper().startswith('#REDIRECT'): | |||||
| self.logger.debug(f'[[{title}]]: Skipping redirect') | |||||
| return False | |||||
| taxoboxes = wikitext.filter_templates( | |||||
| matches=lambda tmpl: tmpl.name.matches(('Speciesbox', 'Automatic taxobox'))) | |||||
| if not taxoboxes: | |||||
| self.logger.warning(f'[[{title}]]: No taxoboxes found') | |||||
| return False | |||||
| if len(taxoboxes) > 1: | |||||
| self.logger.warning(f'[[{title}]]: Multiple taxoboxes found') | |||||
| return False | |||||
| try: | |||||
| syn_param = taxoboxes[0].get('synonyms') | |||||
| except ValueError: | |||||
| self.logger.debug(f'[[{title}]]: No synonyms parameter in taxobox') | |||||
| return False | |||||
| tmpls = syn_param.value.filter_templates( | |||||
| matches=lambda tmpl: tmpl.name.matches(('Species list', 'Taxon list'))) | |||||
| if not tmpls: | |||||
| # This means the bot's original work is no longer there. In most cases, this is | |||||
| # an unrelated synonym list added by another editor and there is nothing to check, | |||||
| # but it's possible someone converted the bot's list into a different format without | |||||
| # checking the authorities. Those cases need to be manually checked. | |||||
| self.logger.warning(f'[[{title}]]: Could not find a taxa list in taxobox') | |||||
| return False | |||||
| if len(tmpls) > 1: | |||||
| self.logger.warning(f'[[{title}]]: Multiple taxa lists found in taxobox') | |||||
| return False | |||||
| expected = {} | |||||
| for taxon, author in synonyms: | |||||
| if taxon in expected and expected[taxon] != author: | |||||
| # These need to be manually reviewed | |||||
| self.logger.warning(f'[[{title}]]: Expected synonym list has duplicates') | |||||
| return False | |||||
| expected[self._normalize(taxon)] = self._normalize(author) | |||||
| actual = {} | |||||
| formatted_authors = {} | |||||
| splist = tmpls[0] | |||||
| for i in range(len(splist.params) // 2): | |||||
| taxon_param, author_param = splist.params[2 * i], splist.params[2 * i + 1] | |||||
| taxon = self._normalize(taxon_param.value) | |||||
| author = self._normalize(author_param.value) | |||||
| if taxon not in expected: | |||||
| self.logger.warning(f'[[{title}]]: Unknown synonym {taxon!r}') | |||||
| return False | |||||
| actual[taxon] = author | |||||
| formatted_authors.setdefault(author, []).append(author_param.value.strip()) | |||||
| expected = {taxon: author for taxon, author in expected.items() if taxon in actual} | |||||
| assert set(expected.keys()) == set(actual.keys()) | |||||
| if expected == actual: | |||||
| self.logger.debug(f'[[{title}]]: Nothing to update') | |||||
| return None | |||||
| if list(expected.values()) != list(actual.values()): | |||||
| if set(expected.values()) == set(actual.values()): | |||||
| self.logger.warning(f'[[{title}]]: Actual authors are not in expected order') | |||||
| else: | |||||
| self.logger.warning(f'[[{title}]]: Actual authors do not match expected') | |||||
| return False | |||||
| changes = [] | |||||
| for i in range(len(splist.params) // 2): | |||||
| taxon_param, author_param = splist.params[2 * i], splist.params[2 * i + 1] | |||||
| taxon = self._normalize(taxon_param.value) | |||||
| if expected[taxon] != actual[taxon]: | |||||
| author = formatted_authors[expected[taxon]].pop(0) | |||||
| match = re.match(r'^(\s*).*?(\s*)$', str(author_param.value)) | |||||
| ws_before, ws_after = match.group(1), match.group(2) | |||||
| author_param.value = f'{ws_before}{author}{ws_after}' | |||||
| changes.append((taxon, actual[taxon], expected[taxon])) | |||||
| if changes: | |||||
| self.logger.info(f'Will update {len(changes)} synonyms in [[{title}]]') | |||||
| else: | |||||
| self.logger.debug(f'Nothing to update in [[{title}]]') | |||||
| return changes | |||||
| @staticmethod | |||||
| def _normalize(value): | |||||
| """ | |||||
| Normalize a taxon or author name. | |||||
| """ | |||||
| if isinstance(value, mwparserfromhell.wikicode.Wikicode): | |||||
| value = value.strip_code() | |||||
| if not value or not value.strip(): | |||||
| return None | |||||
| return unidecode.unidecode(value.strip().casefold().replace('&', 'and').replace(',', '')) | |||||
| def view_edits(self): | |||||
| """ | |||||
| Examine edits prepared by prepare_edits. | |||||
| """ | |||||
| with open(self.edits_path) as fp: | |||||
| edits = json.load(fp) | |||||
| self.logger.info(f'{len(edits)} pages to edit') | |||||
| for pageid, edit in edits.items(): | |||||
| print(f'\n{pageid}: {edit["title"]}:') | |||||
| old, new = edit['original'], edit['content'] | |||||
| udiff = difflib.unified_diff(old.splitlines(), new.splitlines(), 'old', 'new') | |||||
| subprocess.run( | |||||
| ['delta', '-s', '--paging', 'never'], | |||||
| input='\n'.join(udiff), text=True | |||||
| ) | |||||
| def save_edits(self): | |||||
| """ | |||||
| Save edits prepared by prepare_edits. | |||||
| """ | |||||
| with open(self.edits_path) as fp: | |||||
| edits = json.load(fp) | |||||
| self.logger.info(f'{len(edits)} pages to edit') | |||||
| for pageid, edit in edits.items(): | |||||
| page = self.site.get_page(edit['title']) | |||||
| self.logger.info(f'\n{pageid}: [[{page.title}]]') | |||||
| if self.shutoff_enabled(page): | |||||
| raise RuntimeError('Shutoff enabled') | |||||
| if not page.check_exclusion(): | |||||
| self.logger.warning(f'[[{page.title}]]: Bot excluded from editing') | |||||
| return | |||||
| page.edit( | |||||
| edit['content'], | |||||
| summary=self.summary.format(changes=len(edit['changes'])), | |||||
| baserevid=edit['revid'], | |||||
| ) | |||||
| time.sleep(10) | |||||