PK!?Vwccls/__init__.pyfrom .wccls import * # pylint: disable=wildcard-import from .wccls_desktop import * # pylint: disable=wildcard-import from .wccls_mobile import * # pylint: disable=wildcard-import from .bibliocommons import * # pylint: disable=wildcard-import # mobile works at the moment Wccls = WcclsMobile __all__ = wccls.__all__ + wccls_desktop.__all__ + wccls_mobile.__all__ + bibliocommons.__all__ # pylint: disable=undefined-variable PK!KDGwccls/bibliocommons.pyfrom datetime import datetime from logging import info from os.path import join from re import search from tempfile import gettempdir from requests_html import HTMLSession from .wccls import ActiveHold, CheckedOutItem, HeldItem, ShippedItem, SuspendedHold __all__ = ["WcclsBiblioCommons"] class WcclsBiblioCommons: def __init__(self, login, password, debug_=False): self._debug = debug_ self._session = HTMLSession() self._Login(login, password) self.items = self._CheckedOut() + self._ReadyForPickup() + self._InTransit() + self._NotYetAvailable() + self._Suspended() def _Login(self, login, password): loginPage = self._session.get("https://wccls.bibliocommons.com/user/login") loginForm = loginPage.html.find(".loginForm", first=True) formData = {} for input_ in loginForm.find("input"): formData[input_.attrs["name"]] = input_.attrs["value"] if "value" in input_.attrs else "" formData["user_pin"] = password formData["name"] = login _ = self._session.post(loginForm.attrs["action"], data=formData) def _DumpDebugFile(self, filename, content): if not self._debug: return with open(join(gettempdir(), "log", filename), "wb") as theFile: theFile.write(content) def _ParseItems(self, url, dumpfile, parseFunction): result = [] page = self._session.get(url) self._DumpDebugFile(dumpfile, page.content) for listItem in page.html.find(".listItem"): info(listItem) result.append(parseFunction(listItem)) return result def _Suspended(self): return self._ParseItems("https://wccls.bibliocommons.com/holds/index/suspended", "suspended.html", _ParseSuspended) def _NotYetAvailable(self): return self._ParseItems("https://wccls.bibliocommons.com/holds/index/not_yet_available", "not-yet-available.html", _ParseNotYetAvailable) def _ReadyForPickup(self): return self._ParseItems("https://wccls.bibliocommons.com/holds/index/ready_for_pickup", "ready-for-pickup.html", _ParseReadyForPickup) def _InTransit(self): return self._ParseItems("https://wccls.bibliocommons.com/holds/index/in_transit", "in-transit.html", _ParseInTransit) def _CheckedOut(self): return self._ParseItems("https://wccls.bibliocommons.com/checkedout", "checked-out.html", _ParseCheckedOut) def _ParseSuspended(listItem): return SuspendedHold( title=listItem.find(".title", first=True).text, reactivationDate=_ParseDate2(listItem)) def _ParseNotYetAvailable(listItem): holdInfo = _ParseHoldPosition(listItem) return ActiveHold( title=listItem.find(".title", first=True).text, activationDate=_ParseDate3(listItem), queuePosition=holdInfo[0], queueSize=None, # Not shown on the initial screen anymore copies=holdInfo[1]) def _ParseReadyForPickup(listItem): return HeldItem( title=listItem.find(".title", first=True).text, expiryDate=_ParseDate("Pickup by: ", listItem.find(".pick_up_date", first=True))) def _ParseInTransit(listItem): return ShippedItem( title=listItem.find(".title", first=True).text, shippedDate=None) # they don't seem to show this anymore def _ParseCheckedOut(listItem): return CheckedOutItem( title=listItem.find(".title", first=True).text, dueDate=_ParseDate("Due on: \xa0", listItem.find(".checkedout_due_date", first=True)), renewals=None, # need an example isOverdrive=False) # need an example def _ParseDate(prefix, element): assert element.text.startswith(prefix), f"{element.text}" return datetime.strptime(element.text[len(prefix):], "%b %d, %Y").date() def _ParseDate2(listItem): dateAttr = listItem.find("a[data-value]", first=True) # text value here seems to have been run through some javascript return datetime.strptime(dateAttr.text, "%b %d, %Y").date() def _ParseDate3(listItem): dateAttr = listItem.find(".hold_expiry_date", first=True) return datetime.strptime(dateAttr.text, "%b %d, %Y").date() def _ParseHoldPosition(listItem): text = listItem.find(".hold_position", first=True).text match = search(r"\#(\d+) on (\d+) cop", text) return (match.group(1), match.group(2)) PK!#yd7 7 wccls/wccls.pyfrom enum import Enum __all__ = ["ParseError", "StatusType", "Item", "CheckedOutItem", "SuspendedHold", "HeldItem", "ShippedItem", "ActiveHold", "CancelledHold", "PendingItem", "UnclaimedHold"] class ParseError(Exception): pass # status types StatusType = Enum("StatusType", "Held, Pending, Shipped, Active, Inactive, Cancelled, CheckedOut, Unclaimed") class Item: def __init__(self, title, status): self.title = title self.status = status def __repr__(self): return f"" class CheckedOutItem(Item): def __init__(self, title, dueDate, renewals, isOverdrive): super().__init__(title, StatusType.CheckedOut) self.dueDate = dueDate self.renewals = renewals self.isOverdrive = isOverdrive def __repr__(self): return f"" # The same as a paused hold class SuspendedHold(Item): def __init__(self, title, reactivationDate): super().__init__(title, StatusType.Inactive) self.reactivationDate = reactivationDate def __repr__(self): return f"" # Being held at the library class HeldItem(Item): def __init__(self, title, expiryDate): super().__init__(title, StatusType.Held) self.expiryDate = expiryDate def __repr__(self): return f"" # Shipping to the library class ShippedItem(Item): def __init__(self, title, shippedDate): super().__init__(title, StatusType.Shipped) self.shippedDate = shippedDate def __repr__(self): return f"" # In the queue class ActiveHold(Item): def __init__(self, title, activationDate, queuePosition, queueSize, copies): super().__init__(title, StatusType.Active) self.activationDate = activationDate self.queuePosition = queuePosition self.queueSize = queueSize self.copies = copies def __repr__(self): return f"" # Cancelled - not sure if this shows up anymore class CancelledHold(Item): def __init__(self, title, cancellationDate): super().__init__(title, StatusType.Cancelled) self.cancellationDate = cancellationDate def __repr__(self): return f"" # This is the state after it's been requested but before it's an ActiveHold class PendingItem(Item): def __init__(self, title, reservationDate): super().__init__(title, StatusType.Pending) self.reservationDate = reservationDate def __repr__(self): return f"" # The hold expired - not sure if this shows up anymore class UnclaimedHold(Item): def __init__(self, title): super().__init__(title, StatusType.Unclaimed) PK!{>55wccls/wccls_desktop.pyfrom datetime import datetime, timedelta from logging import debug from os.path import join from re import search from bs4 import BeautifulSoup from requests import Session from .wccls import ActiveHold, CancelledHold, CheckedOutItem, HeldItem, PendingItem, ShippedItem, SuspendedHold, UnclaimedHold __all__ = ["WcclsDesktop"] class WcclsDesktop: def __init__(self, login, password, debug_=False, host='https://catalog.wccls.org'): self._debug = debug_ self._host = host self._session = Session() self._Login(login, password) self.items = self._CheckedOutItems() + self._Holds() def _Login(self, login, password): # first get https://catalog.wccls.org/polaris/logon.aspx # and pull the __VIEWSTATE parameter out of it # posts to the same place loginUrl = self._host + '/polaris/logon.aspx' # display of login page is https://catalog.wccls.org/polaris/logon.aspx headers = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36', } response = self._session.get(loginUrl, headers=headers, timeout=60) response.raise_for_status() self._SaveDebugFile("logon.html", response.content) soup = BeautifulSoup(response.text, "html.parser") viewState = soup.find_all(id="__VIEWSTATE")[0]["value"] viewStateGenerator = soup.find_all(id="__VIEWSTATEGENERATOR")[0]["value"] eventValidation = soup.find_all(id="__EVENTVALIDATION")[0]["value"] loginParameters = { '__VIEWSTATE': viewState, '__VIEWSTATEGENERATOR': viewStateGenerator, '__EVENTVALIDATION': eventValidation, "textboxBarcodeUsername": login, "textboxPassword": password, "buttonSubmit": "Log+In" } headers = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Host": "catalog.wccls.org", "Connection": "keep-alive", 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36', "Origin": "Origin: https://catalog.wccls.org", "Content-Type": "application/x-www-form-urlencoded" } r = self._session.post(loginUrl, data=loginParameters, headers=headers, timeout=60) r.raise_for_status() self._SaveDebugFile("after-login.html", response.content) def _SaveDebugFile(self, filename, content): if self._debug: from tempfile import gettempdir with open(join(gettempdir(), "log", filename), "wb") as theFile: theFile.write(content) def _ParseHoldDate(self, dateString): # pylint: disable=no-self-use if dateString == "(until tomorrow)": days = 1 elif dateString == "(until today)": days = 0 else: match = search(r'for (\d+) more day', dateString) days = int(match.group(1)) return (datetime.today() + timedelta(days=days)).date() def _ParseShippedDate(self, dateString): # pylint: disable=no-self-use if dateString == "(yesterday)": days = 1 elif dateString == "(today)": days = 0 else: match = search(r"\((\d+) days ago\)", dateString) days = int(match.group(1)) return (datetime.today() - timedelta(days=days)).date() def _ParseQueueText(self, queueText): # pylint: disable=no-self-use splits = queueText.split(' of ') return (int(splits[0].strip()), int(splits[1].strip())) def _CheckedOutItems(self): itemsOutUrl = self._host + '/polaris/patronaccount/itemsout.aspx' r = self._session.get(itemsOutUrl, timeout=60) r.raise_for_status() self._SaveDebugFile("desktop-checked-out.html", r.content) soup = BeautifulSoup(r.text, "html.parser") items = [] for row in soup.find_all(class_="patrongrid-row") + soup.find_all(class_="patrongrid-alternating-row"): tds = row.find_all("td") renewalsTds = tds[7] assert False, "Overdrive doesn't work" items.append(CheckedOutItem(title=tds[4].span.a.contents[0], dueDate=datetime.strptime(tds[6].span.contents[0], "%m/%d/%Y").date(), renewals=int(renewalsTds.span.contents[0]) if renewalsTds.span is not None else None, isOverdrive=False)) return items def _Holds(self): url = self._host + '/polaris/patronaccount/requests.aspx' r = self._session.get(url, timeout=60) r.raise_for_status() self._SaveDebugFile("desktop-holds.html", r.content) soup = BeautifulSoup(r.text, "html.parser") items = [] for row in soup.find_all(class_="patrongrid-row") + soup.find_all(class_="patrongrid-alternating-row"): tds = row.find_all("td") title = tds[3].span.a.contents[0] status = tds[5].span.contents[0] dateInfo = tds[5].find_all("span")[1].contents[0] if status == "Held": item = HeldItem(title=title, expiryDate=self._ParseHoldDate(dateInfo)) elif status == "Active": queueData = self._ParseQueueText(tds[6].span.contents[0]) item = ActiveHold(title=title, activationDate=datetime.strptime(dateInfo, "(since %m/%d/%Y)").date(), queuePosition=queueData[0], queueSize=queueData[1], copies=None) elif status == "Shipped": item = ShippedItem(title=title, shippedDate=self._ParseShippedDate(dateInfo)) elif status == "Inactive": item = SuspendedHold(title=title, reactivationDate=datetime.strptime(dateInfo, "(until %m/%d/%Y)").date()) elif status == "Cancelled": item = CancelledHold(title=title, cancellationDate=datetime.strptime(dateInfo, "(on %m/%d/%Y)").date()) elif status == "Pending": reservationDate = datetime.strptime(dateInfo, "(as of %m/%d/%Y)").date() item = PendingItem(title=title, reservationDate=reservationDate) elif status == "Unclaimed": item = UnclaimedHold(title=title) else: debug(f"Status: {status}, dateInfo: {dateInfo}") assert False items.append(item) return items PK!n44wccls/wccls_mobile.py# coding=UTF-8 from datetime import datetime, timedelta from logging import debug, error, warning from os.path import join from re import match, search from tempfile import gettempdir from bs4 import BeautifulSoup from requests import Session from .wccls import ActiveHold, CancelledHold, CheckedOutItem, HeldItem, ParseError, PendingItem, ShippedItem, SuspendedHold __all__ = ["WcclsMobile"] class WcclsMobile: def __init__(self, login, password, debug_=False): self._host = "https://catalog.wccls.org" self._debug = debug_ self._session = Session() self._Login(login, password) self.items = self._CheckedOutItems() + self._Holds() def _Login(self, login, password): # get on this returns the login form, post logs in loginUrl = self._host + '/Mobile/MyAccount/Logon' loginParameters = { 'barcodeOrUsername': login, 'password': password, 'rememberMe': 'true' # doesn't seem to matter whether we say true or false } response = self._session.post(loginUrl, data=loginParameters) debug(f"Login reponse: {response}") def _ParseCheckedOutItem(self, tr): # pylint: disable=no-self-use,too-many-locals td = tr("td")[1] # zeroth td is the renewal checkbox title = td("a")[0].text # title is in the tag allText = td.text.strip()[len(title):] splits = allText.split(' renewals leftDue: ') if len(splits) == 2: renewals = int(splits[0]) datePlus = splits[1] justDate = match(r"(\d+/\d+/\d{4})", datePlus).group(1) dueDate = datetime.strptime(justDate, '%m/%d/%Y').date() isOverdrive = td("select") assert isinstance(isOverdrive, list) return CheckedOutItem(title, dueDate, renewals, len(isOverdrive) != 0) splits = allText.split("\xa0") if len(splits) >= 2: dueDate = datetime.strptime(splits[1], '%m/%d/%Y').date() return CheckedOutItem(title, dueDate, 0, True) assert False, "Unexpected format" index = allText.find("Due:") if index != -1: dueDate = datetime.strptime(allText[index + 5:], '%m/%d/%Y').date() return CheckedOutItem(title, dueDate, 0, False) warning("Failed to parse: " + allText.strip()) return None def _ParseCheckedOutPage(self, pageNumber): response = self._session.get(f"{self._host}/Mobile/MyAccount/ItemsOut?page={pageNumber}", timeout=60) self._DumpDebugFile(f"itemsout-{pageNumber}.html", response.text) soup = BeautifulSoup(response.content, "html.parser") items = [] for tr in soup.find_all(lambda e: e.name == "tr" and len(e("td")) != 0): checkedOutItem = self._ParseCheckedOutItem(tr) if checkedOutItem is not None: items.append(checkedOutItem) return soup, items def _CheckedOutItems(self): # pylint: disable=too-many-locals soup, items = self._ParseCheckedOutPage(0) breadcrumbsDiv = soup.find_all('div', id='breadcrumbs') breadcrumbsText = breadcrumbsDiv[0].text totalItemsMatch = search(r'\((\d+)', breadcrumbsText) if totalItemsMatch is None: return [] totalItems = int(totalItemsMatch.group(1)) itemsPerPage = 5 totalPages = -(-totalItems // itemsPerPage) # round up debug(f"totalItems={totalItems}, totalPages={totalPages}") for page in range(1, totalPages): items.extend(self._ParseCheckedOutPage(page)[1]) # assert(totalItems == len(items)) return items def _ParseHold(self, tr): # pylint: disable=no-self-use,too-many-return-statements,too-many-locals td1 = tr('td')[1] anchors = td1.find_all('a') title = anchors[0].text if len(anchors) >= 2 and anchors[1].text == 'Check Out': # the desktop site has the expiry date, but not the mobile site return HeldItem(title=title, expiryDate=None) text = td1.text[len(title):] match_ = search(r'(Held|Pending|Shipped|Active|Inactive|Cancelled|Unclaimed)\s*\((.*)\)', text) if match_ is None: errorMessage = f'Failed to parse hold. text="{text}"' error(errorMessage) raise ParseError(errorMessage) status = match_.group(1) date = match_.group(2) if status == "Pending": return PendingItem(title=title, reservationDate=datetime.strptime(date.strip(), "as of %m/%d/%Y").date()) if status == "Shipped": date = datetime.now().date() - timedelta(days=int(date.strip()[:-8])) return ShippedItem(title=title, shippedDate=date) if status == "Active": date = datetime.strptime(date.strip()[6:], '%m/%d/%Y').date() td2 = tr('td')[2] splits = td2.text.strip().split(' Of ') listPos = int(splits[0].strip()) listSize = int(splits[1].strip()) return ActiveHold(title=title, activationDate=date, queuePosition=listPos, queueSize=listSize, copies=None) if status == "Inactive": date = datetime.strptime(date.strip()[6:], '%m/%d/%Y').date() return SuspendedHold(title=title, reactivationDate=date) if status == "Cancelled": date = datetime.strptime(date.strip()[3:], '%m/%d/%Y').date() return CancelledHold(title=title, cancellationDate=date) if status == "Held": if date == "until tomorrow": days = 1 elif date == "until today": days = 0 else: match_ = search(r'for (\d+) more day', date) days = int(match_.group(1)) date = (datetime.today() + timedelta(days=days)).date() return HeldItem(title=title, expiryDate=date) if status == "Unclaimed": raise ParseError("Unclaimed stuff is now cancelled") raise ParseError(f"Unknown status type: {status}, text={text}") def _ParseHoldsPage(self, pageNumber): response = self._session.get(f"{self._host}/Mobile/MyAccount/Holds?page={pageNumber}", timeout=60) self._DumpDebugFile(f"holds-{pageNumber}.html", response.text) soup = BeautifulSoup(response.content, "html.parser") items = [] for tr in soup.find_all(lambda e: e.name == "tr" and len(e("td")) != 0): hold = self._ParseHold(tr) items.append(hold) return soup, items def _Holds(self): try: soup, items = self._ParseHoldsPage(0) footer = soup.find_all("div", class_="list-footer-options")[0].text pages = int(search(r"Page\s+1\s+of\s+(\d+)", footer).group(1)) for page in range(1, pages): items.extend(self._ParseHoldsPage(page)[1]) except IndexError as e: raise ParseError from e return items def _DumpDebugFile(self, filename, content): if not self._debug: return with open(join(gettempdir(), "log", filename), "w") as theFile: theFile.write(content) PK!`LLwccls-1.2.0.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2018 Rehan Khwaja 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. PK!H$TTwccls-1.2.0.dist-info/WHEEL = 0 нR \C HCoo~ \B"|lchлYh|hzWٓ7}|v }PK!Hwccls-1.2.0.dist-info/METADATAKO1suBxTXZE%B.k{;'k'(*sͺm &r_\k Q5ˉYrNӋ!= c a:6&$0ŷ*'hZ\F-/=Z}Ž8KRp}m+t ]Hw;YXݾd0'|Zc1ubw 0g8c)RpRh\(=n.蹢B,,Q?bּ+s6Lzace nϦ˜7]td$5hBռ)V ʾӇhj#c\jnJ-dEUk y< PK!Hmwccls-1.2.0.dist-info/RECORDuй@|v`AcPhwjvr?JĸhY{g ɏ9Ny\6B4y>ֲmN٦tF+Sҗm_8K WY#z VP ͖Ѳr!(\M"/ԙ^xM4a= !HE:q 5;K~Es.+^y] $u{VBr%=a#57.c7ASij{aL`Qz$%9ǘ-!_[f!-]򉢰.}B  U'wqr?n4|e$ Qw[1PK!?Vwccls/__init__.pyPK!KDGwccls/bibliocommons.pyPK!#yd7 7 (wccls/wccls.pyPK!{>55wccls/wccls_desktop.pyPK!n445wccls/wccls_mobile.pyPK!`LL[Owccls-1.2.0.dist-info/LICENSEPK!H$TTSwccls-1.2.0.dist-info/WHEELPK!HoTwccls-1.2.0.dist-info/METADATAPK!HmVwccls-1.2.0.dist-info/RECORDPK pX