PK]&Nooosensebook/__init__.py"""Making sense of Facebooks undocumented API.""" __title__ = "SenseBook" __version__ = "0.1.1" __all__ = () PK]&NUmh--sensebook/rq/__init__.pyfrom ._login import * from ._listen import * PK]&Nsensebook/rq/_listen.pyimport attr import logging import requests import time from typing import Any, Iterable, Optional from .. import sansio __all__ = ("Listener",) log = logging.getLogger(__name__) @attr.s(slots=True, kw_only=True) class Listener: _session = attr.ib(type=requests.Session) _listener = attr.ib(factory=sansio.Listener, type=sansio.Listener) def _sleep(self) -> None: delay = self._listener.get_delay() if delay is not None: print("Sleeping for {} seconds.".format(delay)) time.sleep(delay) def _step(self) -> Iterable[Any]: request = self._listener.next_request() try: r = self._session.request( request.method, request.url, timeout=(request.connect_timeout, request.read_timeout), ) except requests.ConnectionError: self._listener.handle_connection_error() except requests.ConnectTimeout: self._listener.handle_connect_timeout() except requests.ReadTimeout: self._listener.handle_read_timeout() else: yield from self._listener.handle(r.status_code, r.content) def pull(self) -> Iterable[Any]: while True: self._sleep() yield from self._step() PK]&Ny sensebook/rq/_login.pyimport attr import re import requests from typing import ClassVar from bs4 import BeautifulSoup as bs __all__ = ("Login",) @attr.s(slots=True, kw_only=True) class Login: """Core methods for logging in to and out of Facebook""" _session = attr.ib(type=requests.Session) BASE_URL = "https://facebook.com" MOBILE_URL = "https://m.facebook.com" LOGIN_URL = "{}/login.php?login_attempt=1".format(MOBILE_URL) FIND_FB_DTSG = re.compile(r'name="fb_dtsg" value="(.*?)"') FIND_CLIENT_REVISION = re.compile(r'"client_revision":(.*?),') FIND_LOGOUT_VALUE = re.compile(r'name=\\"h\\" value=\\"(.*?)\\"') def _set_fb_dtsg_html(self, html: str) -> None: soup = bs(html, "html.parser") elem = soup.find("input", {"name": "fb_dtsg"}) if elem: fb_dtsg = elem.get("value") else: # Fallback to regex fb_dtsg = self.FIND_FB_DTSG.search(html).group(1) self._session.params["fb_dtsg"] = fb_dtsg def _set_default_params(self) -> None: resp = self._session.get(self.BASE_URL) rev = self.FIND_CLIENT_REVISION.search(resp.text).group(1) self._session.params = { "__rev": rev, "__user": self._session.cookies["c_user"], "__a": "1", } self._set_fb_dtsg_html(resp.text) @classmethod def login(cls, email: str, password: str) -> "Login": """Initialize and login, storing the cookies in the session Args: email: Facebook `email`, `id` or `phone number` password: Facebook account password """ self = cls(session=requests.Session()) r = self._session.get(self.MOBILE_URL) soup = bs(r.text, "html.parser") data = { elem["name"]: elem["value"] for elem in soup.find_all("input") if elem.has_attr("value") and elem.has_attr("name") } data["email"] = email data["pass"] = password data["login"] = "Log In" r = self._session.post(self.LOGIN_URL, data=data) if "c_user" not in self._session.cookies: raise ValueError("Could not login, failed on: {}".format(r.url)) self._set_default_params() return self def logout(self) -> None: """Properly log out and invalidate the session""" r = self._session.post("/bluebar/modern_settings_menu/", data={"pmid": "4"}) logout_h = self.FIND_LOGOUT_VALUE.search(r.text).group(1) self._session.get("/logout.php", params={"ref": "mb", "h": logout_h}) def is_logged_in(self) -> bool: """Check the login status Return: Whether the session is still logged in """ # Call the login url, and see if we're redirected to the home page r = self._session.get(self.LOGIN_URL, allow_redirects=False) return "Location" in r.headers and "home" in r.headers["Location"] PK]&NWJ]]sensebook/sansio/__init__.pyfrom ._exceptions import * from ._utils import * from ._abc import * from ._listen import * PK]&Nllsensebook/sansio/_abc.pyimport abc from typing import Dict, Any, Optional from ._utils import build_url __all__ = ("ABCRequest",) class ABCRequest(metaclass=abc.ABCMeta): __slots__ = () @property @abc.abstractmethod def method(self) -> str: raise NotImplementedError @property @abc.abstractmethod def host(self) -> str: raise NotImplementedError @property @abc.abstractmethod def target(self) -> str: raise NotImplementedError @property @abc.abstractmethod def params(self) -> Dict[str, Any]: raise NotImplementedError @property def read_timeout(self) -> Optional[float]: return None @property def connect_timeout(self) -> Optional[float]: return None @property def url(self) -> str: return build_url(host=self.host, target=self.target, params=self.params) PK]&N{sensebook/sansio/_backoff.pyimport attr import random from typing import Optional, ClassVar, Callable __all__ = ("Backoff",) @attr.s(slots=True, kw_only=True) class Backoff: func = attr.ib(type=Callable[[float], float]) jitter = attr.ib(type=Callable[[float], float]) _tries = attr.ib(0, type=int) _delay_override = attr.ib(None, type=float) @classmethod def expo(cls, *, max_time, factor, **kwargs) -> "Backoff": def func(tries: float) -> float: return min(factor * 2 ** max(0, tries - 1), max_time) return cls(func=func, **kwargs) @property def tries(self): return self._tries def do(self) -> None: self._tries += 1 def reset(self) -> None: self._tries = 0 def override(self, value: float) -> None: self._delay_override = value def reset_override(self) -> None: self._delay_override = None def get_delay(self) -> Optional[float]: if self._delay_override: return self._delay_override if self.tries > 0: return self._compute_delay(self.tries) return None def get_randomized_delay(self) -> Optional[float]: delay = self.get_delay() if delay is None: return None return self.jitter(delay) PK]&Nqκ sensebook/sansio/_exceptions.py__all__ = () PK]&N΋sensebook/sansio/_listen.pyimport attr import random import logging from typing import Optional, Dict, Iterable, Any, List from . import _utils, _abc, _backoff log = logging.getLogger(__name__) __all__ = ("ProtocolError", "PullRequest", "Listener") class ProtocolError(Exception): """Raised if some assumption we made about Facebook's protocol is incorrect.""" def __init__(self, msg, data=None): self.data = data if isinstance(data, dict): self.type = data.get("t") else: self.type = None super().__init__(msg) @attr.s(slots=True, kw_only=True) class PullRequest(_abc.ABCRequest): """Handles polling for events.""" params = attr.ib(type=Dict[str, Any]) method = "GET" host = "0-edge-chat.facebook.com" target = "/pull" #: The server holds the request open for 50 seconds read_timeout = 60 #: Slighty over a multiple of 3, see `TCP packet retransmission window` connect_timeout = 10 # TODO: Might be a bit too high @attr.s(slots=True, kw_only=True) class Listener: mark_alive = attr.ib(False, type=bool) _backoff = attr.ib(type=_backoff.Backoff) _clientid = attr.ib(type=str) _sticky_token = attr.ib(None, type=str) _sticky_pool = attr.ib(None, type=str) _seq = attr.ib(0, type=int) @_backoff.default def _default_backoff(self): def jitter(value): return value * random.uniform(1.0, 1.5) return _backoff.Backoff.expo(max_time=320, factor=5, jitter=jitter) @_clientid.default def _default_client_id(self): return _utils.random_hex(31) def _parse_seq(self, data: Any) -> int: # Extract a new `seq` from pull data, or return the old # The JS code handles "sequence regressions", and sends a `msgs_recv` parameter # back, but we won't bother, since their detection is broken (they don't reset # `msgs_recv` when `seq` resets) # `s` takes precedence over `seq` if "s" in data: return int(data["s"]) if "seq" in data: return int(data["seq"]) return self._seq @staticmethod def _safe_status_code(status_code): return 200 <= status_code < 300 def _handle_status(self, status_code, body): if status_code == 503: # In Facebook's JS code, this delay is set by their servers on every call to # `/ajax/presence/reconnect.php`, as `proxy_down_delay_millis`, but we'll # just set a sensible default self._backoff.override(60) log.error("Server is unavailable") else: raise ProtocolError( "Unknown server error response: {}".format(status_code), body ) def _parse_body(self, body: bytes) -> Dict[str, Any]: try: decoded = body.decode("utf-8") except UnicodeDecodeError as e: raise ProtocolError("Invalid unicode data", body) from e try: return _utils.load_json(_utils.strip_json_cruft(decoded)) except ValueError as e: raise ProtocolError("Invalid JSON data", body) from e def _handle_data(self, data: Dict[str, Any]) -> Iterable[Any]: # Don't worry if you've never seen a lot of these types, this is implemented # based on reading the JS source for Facebook's `ChannelManager` self._seq = self._parse_seq(data) type_ = data.get("t") method = getattr(self, "_handle_type_{}".format(type_), None) if method: return method(data) or () else: raise ProtocolError("Unknown protocol message", data) # Type handlers def _handle_type_backoff(self, data): log.warning("Server told us to back off") self._backoff.do() def _handle_type_batched(self, data): for item in data["batches"]: yield from self._handle_data(item) def _handle_type_continue(self, data): self._backoff.reset() raise ProtocolError("Unused protocol message `test_streaming`", data) def _handle_type_fullReload(self, data): # Not yet sure what consequence this has. # But I know that if this is sent, then some messages/events may not have been # sent to us, so we should query for them with a graphqlbatch-something. self._backoff.reset() if "ms" in data: return data["ms"] def _handle_type_heartbeat(self, data): # Request refresh, no need to do anything log.debug("Heartbeat") def _handle_type_lb(self, data): lb_info = data["lb_info"] self._sticky_token = lb_info["sticky"] if "pool" in lb_info: self._sticky_pool = lb_info["pool"] def _handle_type_msg(self, data): self._backoff.reset() return data["ms"] def _handle_type_refresh(self, data): # We don't perform the call, it's quite complicated, and perhaps unnecessary? raise ProtocolError( "The server told us to call `/ajax/presence/reconnect.php`." "This might mean our data representation is wrong!", data, ) _handle_type_refreshDelay = _handle_type_refresh def _handle_type_test_streaming(self, data): raise ProtocolError("Unused protocol message `test_streaming`", data) # Public methods def get_delay(self) -> Optional[float]: return self._backoff.get_randomized_delay() def next_request(self) -> PullRequest: self._backoff.reset_override() # TODO: Not sure if putting this here is correct return PullRequest( params={ "clientid": self._clientid, "sticky_token": self._sticky_token, "sticky_pool": self._sticky_pool, "msgs_recv": 0, "seq": self._seq, "state": "active" if self.mark_alive else "offline", } ) def handle_connection_error(self) -> None: log.exception("Could not pull") self._backoff.do() # Unsure def handle_connect_timeout(self) -> None: log.exception("Connection lost") # Keep trying every minute self._backoff.override(60) def handle_read_timeout(self) -> None: log.debug("Read timeout") # The server might not send data for a while, so we just try again def handle(self, status_code: int, body: bytes) -> Iterable[Any]: """Handle pull protocol body, and yield data frames ready for further parsing""" if not self._safe_status_code(status_code): self._handle_status(status_code, body) return data = self._parse_body(body) yield from self._handle_data(data) # class StreamingListener(Listener): # """Handles listening for events, using a streaming pull request""" # def _get_pull_params(self): # rtn = super()._get_pull_params() # rtn["mode"] = "stream" # rtn["format"] = "json" # return rtn # async def pull(self): # try: # r = await self._pull(stream=True) # return list(r.iter_json()) # except (requests.ConnectionError, requests.Timeout): # # If we lost our connection, keep trying every minute # await trio.sleep(60) # return None PK]&N)&&sensebook/sansio/_utils.pyimport datetime import json import random import urllib.parse from typing import Dict, Any __all__ = ( "build_url", "strip_json_cruft", "load_json", "time_from_millis", "random_hex", ) def build_url( *, host: str, target: str, params: Dict[str, Any], secure: bool = True ) -> str: scheme = "https" if secure else "http" query = urllib.parse.urlencode(params) return urllib.parse.urlunsplit((scheme, host, target, query, "")) def strip_json_cruft(text: str) -> str: """Removes `for(;;);` (and other cruft) that preceeds JSON responses""" try: return text[text.index("{") :] except ValueError: raise ValueError("No JSON object found: {!r}".format(text)) def load_json(text: str) -> Any: return json.loads(text) def time_from_millis(timestamp_in_milliseconds: int) -> datetime: return datetime.datetime.utcfromtimestamp(int(timestamp_in_milliseconds) / 1000) def random_hex(n): return "{:x}".format(random.randint(0, 2 ** n)) # @decorator.decorator # def raises(func, exception_cls: BaseException = None, *args, **kwargs): # try: # return func(*args, **kwargs) # except Exception as e: # if exception_cls is not None and isinstance(e, exception_cls): # raise # raise InternalError from e PK]&N5%sensebook-0.1.1.dist-info/LICENSE.txtBSD 3-Clause License Copyright (c) 2018, Mads Marquart All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. PK!H>*RQsensebook-0.1.1.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,rzd&Y)r$[)T&UrPK!Hm2 "sensebook-0.1.1.dist-info/METADATAWr6}Wl3'MaLcəE)̓C$(!ƅ@)lZ/y{1p 3xBk\E˗RV + LNYZ 8*ufRqyF&8`lK  l6y<6RiiS5󒓤>J E3'ļo+4[EetxHa}eҫb-ǢY ύKu#';lΞGȯ~TFų&[ .3 3(B u[-Tܝ[6@Gl)UyCbz yY~2M"D}k~%4BZ[vX2]L3`[ҔÎ#kլGLg5af@_$ (6f(㼬Ƶ݄GŢvӻ҅abY,ITxZj%pKx^y0;B_jpDN)k5C\ދ tE>O;_ml3탌3UvJ )XHo'u=L4&co|(P>ޓ #-@-bosȳ+`M ~D/^@/$W4|@fj??F|N\R.ot<4Y212nj(:ad[1/{RCnIV/ѧᬶS`Sì:fk(grkhDaMLs֋P49 ^StI&p ~v|MVu TjbJ-Mf2KWfӳ(PK!Hb sensebook-0.1.1.dist-info/RECORD}ɒP}} T ^ A A Ȩ~}W/2:rfDE G4]3${`NI%Nm;IH҂FԾx @*H%bA#۝1Tb?,h> ha4wiPt+]eVs%?U(NwgEQPkG 5wUNKKV,:R; $];:9F ,Pڡ8Pshk-ڛ5a䖳8rkGj 2':m@al Q>$Q7|Vϱ/,ϰ,W&xēcj٦R P!9bb9 $O@9/Z㶳A¦Q>;)Q؈W=/C9 ûm$bwVWnړUd,QơC"lR%G]`Ut#T^Yd~hEwkN_!+ᇱdӕ߇yc%B gmJR)EOl3pfÉF0#Mowo^b)} &i\p5Rc }n,-{ ģ ޫOP>WCq?x8W%?h=dQ AvdrPK]&Nooosensebook/__init__.pyPK]&NUmh--sensebook/rq/__init__.pyPK]&Nsensebook/rq/_listen.pyPK]&Ny Ysensebook/rq/_login.pyPK]&NWJ]]%sensebook/sansio/__init__.pyPK]&Nllsensebook/sansio/_abc.pyPK]&N{^sensebook/sansio/_backoff.pyPK]&Nqκ sensebook/sansio/_exceptions.pyPK]&N΋sensebook/sansio/_listen.pyPK]&N)&&8sensebook/sansio/_utils.pyPK]&N5%.>sensebook-0.1.1.dist-info/LICENSE.txtPK!H>*RQZDsensebook-0.1.1.dist-info/WHEELPK!Hm2 "Dsensebook-0.1.1.dist-info/METADATAPK!Hb [Jsensebook-0.1.1.dist-info/RECORDPK&M