A Python robot that edits Wikipedia and interacts with people over IRC https://en.wikipedia.org/wiki/User:EarwigBot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 jaren geleden
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. # -*- coding: utf-8 -*-
  2. from hashlib import md5
  3. import re
  4. from time import strftime
  5. from urllib import quote
  6. from wiki.exceptions import *
  7. class Page(object):
  8. """
  9. EarwigBot's Wiki Toolset: Page Class
  10. Represents a Page on a given Site. Has methods for getting information
  11. about the page, getting page content, and so on. Category is a subclass of
  12. Page with additional methods.
  13. Public methods:
  14. title -- returns the page's title, or pagename
  15. exists -- returns whether the page exists
  16. pageid -- returns an integer ID representing the page
  17. url -- returns the page's URL
  18. namespace -- returns the page's namespace as an integer
  19. protection -- returns the page's current protection status
  20. creator -- returns the page's creator (first user to edit)
  21. is_talkpage -- returns True if the page is a talkpage, else False
  22. is_redirect -- returns True if the page is a redirect, else False
  23. toggle_talk -- returns a content page's talk page, or vice versa
  24. get -- returns page content
  25. get_redirect_target -- if the page is a redirect, returns its destination
  26. edit -- replaces the page's content or creates a new page
  27. add_section -- add a new section at the bottom of the page
  28. """
  29. def __init__(self, site, title, follow_redirects=False):
  30. """Constructor for new Page instances.
  31. Takes three arguments: a Site object, the Page's title (or pagename),
  32. and whether or not to follow redirects (optional, defaults to False).
  33. As with User, site.get_page() is preferred. Site's method has support
  34. for a default `follow_redirects` value in our config, while __init__
  35. always defaults to False.
  36. __init__ will not do any API queries, but it will use basic namespace
  37. logic to determine our namespace ID and if we are a talkpage.
  38. """
  39. self._site = site
  40. self._title = title.strip()
  41. self._follow_redirects = self._keep_following = follow_redirects
  42. self._exists = 0
  43. self._pageid = None
  44. self._is_redirect = None
  45. self._lastrevid = None
  46. self._protection = None
  47. self._fullurl = None
  48. self._content = None
  49. self._creator = None
  50. # Attributes used for editing/deleting/protecting/etc:
  51. self._token = None
  52. self._basetimestamp = None
  53. self._starttimestamp = None
  54. # Try to determine the page's namespace using our site's namespace
  55. # converter:
  56. prefix = self._title.split(":", 1)[0]
  57. if prefix != title: # ignore a page that's titled "Category" or "User"
  58. try:
  59. self._namespace = self._site.namespace_name_to_id(prefix)
  60. except NamespaceNotFoundError:
  61. self._namespace = 0
  62. else:
  63. self._namespace = 0
  64. # Is this a talkpage? Talkpages have odd IDs, while content pages have
  65. # even IDs, excluding the "special" namespaces:
  66. if self._namespace < 0:
  67. self._is_talkpage = False
  68. else:
  69. self._is_talkpage = self._namespace % 2 == 1
  70. def _force_validity(self):
  71. """Used to ensure that our page's title is valid.
  72. If this method is called when our page is not valid (and after
  73. _load_attributes() has been called), InvalidPageError will be raised.
  74. Note that validity != existence. If a page's title is invalid (e.g, it
  75. contains "[") it will always be invalid, and cannot be edited.
  76. """
  77. if self._exists == 1:
  78. e = "Page '{0}' is invalid.".format(self._title)
  79. raise InvalidPageError(e)
  80. def _force_existence(self):
  81. """Used to ensure that our page exists.
  82. If this method is called when our page doesn't exist (and after
  83. _load_attributes() has been called), PageNotFoundError will be raised.
  84. It will also call _force_validity() beforehand.
  85. """
  86. self._force_validity()
  87. if self._exists == 2:
  88. e = "Page '{0}' does not exist.".format(self._title)
  89. raise PageNotFoundError(e)
  90. def _load_wrapper(self):
  91. """Calls _load_attributes() and follows redirects if we're supposed to.
  92. This method will only follow redirects if follow_redirects=True was
  93. passed to __init__() (perhaps indirectly passed by site.get_page()).
  94. It avoids the API's &redirects param in favor of manual following,
  95. so we can act more realistically (we don't follow double redirects, and
  96. circular redirects don't break us).
  97. This will raise RedirectError if we have a problem following, but that
  98. is a bug and should NOT happen.
  99. If we're following a redirect, this will make a grand total of three
  100. API queries. It's a lot, but each one is quite small.
  101. """
  102. self._load_attributes()
  103. if self._keep_following and self._is_redirect:
  104. self._title = self.get_redirect_target()
  105. self._keep_following = False # don't follow double redirects
  106. self._content = None # reset the content we just loaded
  107. self._load_attributes()
  108. def _load_attributes(self, result=None):
  109. """Loads various data from the API in a single query.
  110. Loads self._title, ._exists, ._is_redirect, ._pageid, ._fullurl,
  111. ._protection, ._namespace, ._is_talkpage, ._creator, ._lastrevid,
  112. ._token, and ._starttimestamp using the API. It will do a query of
  113. its own unless `result` is provided, in which case we'll pretend
  114. `result` is what the query returned.
  115. Assuming the API is sound, this should not raise any exceptions.
  116. """
  117. if result is None:
  118. params = {"action": "query", "rvprop": "user", "intoken": "edit",
  119. "prop": "info|revisions", "rvlimit": 1, "rvdir": "newer",
  120. "titles": self._title, "inprop": "protection|url"}
  121. result = self._site._api_query(params)
  122. res = result["query"]["pages"].values()[0]
  123. # Normalize our pagename/title thing:
  124. self._title = res["title"]
  125. try:
  126. res["redirect"]
  127. except KeyError:
  128. self._is_redirect = False
  129. else:
  130. self._is_redirect = True
  131. self._pageid = result["query"]["pages"].keys()[0]
  132. if int(self._pageid) < 0:
  133. try:
  134. res["missing"]
  135. except KeyError:
  136. # If it has a negative ID and it's invalid, then break here,
  137. # because there's no other data for us to get:
  138. self._exists = 1
  139. return
  140. else:
  141. # If it has a negative ID and it's missing; we can still get
  142. # data like the namespace, protection, and URL:
  143. self._exists = 2
  144. else:
  145. self._exists = 3
  146. self._fullurl = res["fullurl"]
  147. self._protection = res["protection"]
  148. try:
  149. self._token = res["edittoken"]
  150. except KeyError:
  151. pass
  152. else:
  153. self._starttimestamp = strftime("%Y-%m-%dT%H:%M:%SZ")
  154. # We've determined the namespace and talkpage status in __init__()
  155. # based on the title, but now we can be sure:
  156. self._namespace = res["ns"]
  157. self._is_talkpage = self._namespace % 2 == 1 # talkpages have odd IDs
  158. # These last two fields will only be specified if the page exists:
  159. self._lastrevid = res.get("lastrevid")
  160. try:
  161. self._creator = res['revisions'][0]['user']
  162. except KeyError:
  163. pass
  164. def _load_content(self, result=None):
  165. """Loads current page content from the API.
  166. If `result` is provided, we'll pretend that is the result of an API
  167. query and try to get content from that. Otherwise, we'll do an API
  168. query on our own.
  169. Don't call this directly, ever - use .get(force=True) if you want to
  170. force content reloading.
  171. """
  172. if result is None:
  173. params = {"action": "query", "prop": "revisions", "rvlimit": 1,
  174. "rvprop": "content|timestamp", "titles": self._title}
  175. result = self._site._api_query(params)
  176. res = result["query"]["pages"].values()[0]
  177. try:
  178. self._content = res["revisions"][0]["*"]
  179. self._basetimestamp = res["revisions"][0]["timestamp"]
  180. except KeyError:
  181. # This can only happen if the page was deleted since we last called
  182. # self._load_attributes(). In that case, some of our attributes are
  183. # outdated, so force another self._load_attributes():
  184. self._load_attributes()
  185. self._force_existence()
  186. def _get_token(self):
  187. """Tries to get an edit token for the page.
  188. This is actually the same as the delete and protect tokens, so we'll
  189. use it for everything. Raises PermissionError if we're not allowed to
  190. edit the page, otherwise sets self._token and self._starttimestamp.
  191. """
  192. params = {"action": "query", "prop": "info", "intoken": "edit",
  193. "titles": self._title}
  194. result = self._site._api_query(params)
  195. try:
  196. self._token = result["query"]["pages"].values()[0]["edittoken"]
  197. except KeyError:
  198. e = "You don't have permission to edit this page."
  199. raise PermissionsError(e)
  200. else:
  201. self._starttimestamp = strftime("%Y-%m-%dT%H:%M:%SZ")
  202. def title(self, force=False):
  203. """Returns the Page's title, or pagename.
  204. This won't do any API queries on its own unless force is True, in which
  205. case the title will be forcibly reloaded from the API (normalizing it,
  206. and following redirects if follow_redirects=True was passed to
  207. __init__()). Any other methods that do API queries will reload title on
  208. their own, however, like exists() and get().
  209. """
  210. if force:
  211. self._load_wrapper()
  212. return self._title
  213. def exists(self, force=False):
  214. """Returns information about whether the Page exists or not.
  215. The returned "information" is a tuple with two items. The first is a
  216. bool, either True if the page exists or False if it does not. The
  217. second is a string giving more information, either "invalid", (title
  218. is invalid, e.g. it contains "["), "missing", or "exists".
  219. Makes an API query if force is True or if we haven't already made one.
  220. """
  221. cases = {
  222. 0: (None, "unknown"),
  223. 1: (False, "invalid"),
  224. 2: (False, "missing"),
  225. 3: (True, "exists"),
  226. }
  227. if self._exists == 0 or force:
  228. self._load_wrapper()
  229. return cases[self._exists]
  230. def pageid(self, force=False):
  231. """Returns an integer ID representing the Page.
  232. Makes an API query if force is True or if we haven't already made one.
  233. Raises InvalidPageError or PageNotFoundError if the page name is
  234. invalid or the page does not exist, respectively.
  235. """
  236. if self._exists == 0 or force:
  237. self._load_wrapper()
  238. self._force_existence() # missing pages do not have IDs
  239. return self._pageid
  240. def url(self, force=False):
  241. """Returns the page's URL.
  242. Like title(), this won't do any API queries on its own unless force is
  243. True. If the API was never queried for this page, we will attempt to
  244. determine the URL ourselves based on the title.
  245. """
  246. if force:
  247. self._load_wrapper()
  248. if self._fullurl is not None:
  249. return self._fullurl
  250. else:
  251. slug = quote(self._title.replace(" ", "_"), safe="/:")
  252. path = self._site._article_path.replace("$1", slug)
  253. return ''.join((self._site._base_url, path))
  254. def namespace(self, force=False):
  255. """Returns the page's namespace ID (an integer).
  256. Like title(), this won't do any API queries on its own unless force is
  257. True. If the API was never queried for this page, we will attempt to
  258. determine the namespace ourselves based on the title.
  259. """
  260. if force:
  261. self._load_wrapper()
  262. return self._namespace
  263. def protection(self, force=False):
  264. """Returns the page's current protection status.
  265. Makes an API query if force is True or if we haven't already made one.
  266. Raises InvalidPageError if the page name is invalid. Will not raise an
  267. error if the page is missing because those can still be protected.
  268. """
  269. if self._exists == 0 or force:
  270. self._load_wrapper()
  271. self._force_validity() # invalid pages cannot be protected
  272. return self._protection
  273. def creator(self, force=False):
  274. """Returns the page's creator (i.e., the first user to edit the page).
  275. Makes an API query if force is True or if we haven't already made one.
  276. Normally, we can get the creator along with everything else (except
  277. content) in self._load_attributes(). However, due to a limitation in
  278. the API (can't get the editor of one revision and the content of
  279. another at both ends of the history), if our other attributes were only
  280. loaded from get(), we'll have to do another API query. This is done
  281. by calling ourselves again with force=True.
  282. Raises InvalidPageError or PageNotFoundError if the page name is
  283. invalid or the page does not exist, respectively.
  284. """
  285. if self._exists == 0 or force:
  286. self._load_wrapper()
  287. self._force_existence()
  288. if not self._creator and not force:
  289. self.creator(force=True)
  290. return self._creator
  291. def is_talkpage(self, force=False):
  292. """Returns True if the page is a talkpage, else False.
  293. Like title(), this won't do any API queries on its own unless force is
  294. True. If the API was never queried for this page, we will attempt to
  295. determine the talkpage status ourselves based on its namespace ID.
  296. """
  297. if force:
  298. self._load_wrapper()
  299. return self._is_talkpage
  300. def is_redirect(self, force=False):
  301. """Returns True if the page is a redirect, else False.
  302. Makes an API query if force is True or if we haven't already made one.
  303. We will return False even if the page does not exist or is invalid.
  304. """
  305. if self._exists == 0 or force:
  306. self._load_wrapper()
  307. return self._is_redirect
  308. def toggle_talk(self, force=False, follow_redirects=None):
  309. """Returns a content page's talk page, or vice versa.
  310. The title of the new page is determined by namespace logic, not API
  311. queries. We won't make any API queries on our own unless force is True,
  312. and the only reason then would be to forcibly update the title or
  313. follow redirects if we haven't already made an API query.
  314. If `follow_redirects` is anything other than None (the default), it
  315. will be passed to the new Page's __init__(). Otherwise, we'll use the
  316. value passed to our own __init__().
  317. Will raise InvalidPageError if we try to get the talk page of a special
  318. page (in the Special: or Media: namespaces), but we won't raise an
  319. exception if our page is otherwise missing or invalid.
  320. """
  321. if force:
  322. self._load_wrapper()
  323. if self._namespace < 0:
  324. ns = self._site.namespace_id_to_name(self._namespace)
  325. e = "Pages in the {0} namespace can't have talk pages.".format(ns)
  326. raise InvalidPageError(e)
  327. if self._is_talkpage:
  328. new_ns = self._namespace - 1
  329. else:
  330. new_ns = self._namespace + 1
  331. try:
  332. body = self._title.split(":", 1)[1]
  333. except IndexError:
  334. body = self._title
  335. new_prefix = self._site.namespace_id_to_name(new_ns)
  336. # If the new page is in namespace 0, don't do ":Title" (it's correct,
  337. # but unnecessary), just do "Title":
  338. if new_prefix:
  339. new_title = ':'.join((new_prefix, body))
  340. else:
  341. new_title = body
  342. if follow_redirects is None:
  343. follow_redirects = self._follow_redirects
  344. return Page(self._site, new_title, follow_redirects)
  345. def get(self, force=False):
  346. """Returns page content, which is cached if you try to call get again.
  347. Use `force` to forcibly reload page content even if we've already
  348. loaded some. This is good if you want to edit a page multiple times,
  349. and you want to get updated content before you make your second edit.
  350. Raises InvalidPageError or PageNotFoundError if the page name is
  351. invalid or the page does not exist, respectively.
  352. """
  353. if force or self._exists == 0:
  354. # Kill two birds with one stone by doing an API query for both our
  355. # attributes and our page content:
  356. params = {"action": "query", "rvlimit": 1, "titles": self._title,
  357. "prop": "info|revisions", "inprop": "protection|url",
  358. "intoken": "edit", "rvprop": "content|timestamp"}
  359. result = self._site._api_query(params)
  360. self._load_attributes(result=result)
  361. self._force_existence()
  362. self._load_content(result=result)
  363. # Follow redirects if we're told to:
  364. if self._keep_following and self._is_redirect:
  365. self._title = self.get_redirect_target()
  366. self._keep_following = False # don't follow double redirects
  367. self._content = None # reset the content we just loaded
  368. self.get(force=True)
  369. return self._content
  370. # Make sure we're dealing with a real page here. This may be outdated
  371. # if the page was deleted since we last called self._load_attributes(),
  372. # but self._load_content() can handle that:
  373. self._force_existence()
  374. if self._content is None:
  375. self._load_content()
  376. return self._content
  377. def get_redirect_target(self, force=False):
  378. """If the page is a redirect, returns its destination.
  379. Use `force` to forcibly reload content even if we've already loaded
  380. some before. Note that this method calls get() for page content.
  381. Raises InvalidPageError or PageNotFoundError if the page name is
  382. invalid or the page does not exist, respectively. Raises RedirectError
  383. if the page is not a redirect.
  384. """
  385. content = self.get(force)
  386. regexp = "^\s*\#\s*redirect\s*\[\[(.*?)\]\]"
  387. try:
  388. return re.findall(regexp, content, flags=re.IGNORECASE)[0]
  389. except IndexError:
  390. e = "The page does not appear to have a redirect target."
  391. raise RedirectError(e)
  392. def edit(self, text, summary, minor=False, bot=True, force=False):
  393. """Replaces the page's content or creates a new page.
  394. `text` is the new page content, with `summary` as the edit summary.
  395. If `minor` is True, the edit will be marked as minor. If `bot` is true,
  396. the edit will be marked as a bot edit, but only if we actually have a
  397. bot flag.
  398. Use `force` to ignore edit conflicts and page deletions/recreations
  399. that occured between getting our edit token and editing our page. Be
  400. careful with this!
  401. """
  402. if not self._token:
  403. self._get_token()
  404. hashed = md5(text).hexdigest()
  405. params = {"action": "edit", "title": self._title, "text": text,
  406. "token": self._token, "summary": summary, "md5": hashed}
  407. if minor:
  408. params["minor"] = "true"
  409. else:
  410. params["notminor"] = "true"
  411. if bot:
  412. params["bot"] = "true"
  413. if not force:
  414. params["starttimestamp"] = self._starttimestamp
  415. if self._basetimestamp:
  416. params["basetimestamp"] = self._basetimestamp
  417. else:
  418. params["recreate"] = "true"
  419. result = self._site._api_query(params)
  420. print result
  421. def add_section(self, text, title, minor=False, bot=True):
  422. """
  423. """
  424. pass