· 5 years ago · Feb 18, 2021, 10:06 AM
1# Copyright (c) 2017 https://github.com/ping
2#
3# This software is released under the MIT License.
4# https://opensource.org/licenses/MIT
5
6# -*- coding: utf-8 -*-
7
8import logging
9import hmac
10import hashlib
11import uuid
12import json
13import re
14import time
15import random
16from datetime import datetime
17import gzip
18from io import BytesIO
19import warnings
20from socket import timeout, error as SocketError
21from ssl import SSLError
22from .compat import (
23 compat_urllib_parse, compat_urllib_error,
24 compat_urllib_request, compat_urllib_parse_urlparse,
25 compat_http_client)
26from .errors import (
27 ErrorHandler, ClientError,
28 ClientLoginRequiredError, ClientCookieExpiredError,
29 ClientConnectionError
30)
31try: # Python 3:
32 # Not a no-op, we're adding this to the namespace so it can be imported.
33 ConnectionError = ConnectionError # pylint: disable=redefined-builtin
34except NameError: # Python 2:
35 class ConnectionError(Exception):
36 pass
37
38from .constants import Constants
39from .http import ClientCookieJar
40from .endpoints import (
41 AccountsEndpointsMixin, DiscoverEndpointsMixin, FeedEndpointsMixin,
42 FriendshipsEndpointsMixin, LiveEndpointsMixin, MediaEndpointsMixin,
43 MiscEndpointsMixin, LocationsEndpointsMixin, TagsEndpointsMixin,
44 UsersEndpointsMixin, UploadEndpointsMixin, UsertagsEndpointsMixin,
45 CollectionsEndpointsMixin, HighlightsEndpointsMixin,
46 IGTVEndpointsMixin,
47 ClientDeprecationWarning, ClientPendingDeprecationWarning,
48 ClientExperimentalWarning
49)
50
51logger = logging.getLogger(__name__)
52# Force Client deprecation warnings to always appear
53warnings.simplefilter('always', ClientDeprecationWarning)
54warnings.simplefilter('always', ClientPendingDeprecationWarning)
55warnings.simplefilter('default', ClientExperimentalWarning)
56
57
58class Client(AccountsEndpointsMixin, DiscoverEndpointsMixin, FeedEndpointsMixin,
59 FriendshipsEndpointsMixin, LiveEndpointsMixin, MediaEndpointsMixin,
60 MiscEndpointsMixin, LocationsEndpointsMixin, TagsEndpointsMixin,
61 UsersEndpointsMixin, UploadEndpointsMixin, UsertagsEndpointsMixin,
62 CollectionsEndpointsMixin, HighlightsEndpointsMixin,
63 IGTVEndpointsMixin, object):
64 """Main API client class for the private app api."""
65
66 API_URL = 'https://i.instagram.com/api/{version!s}/'
67
68 USER_AGENT = Constants.USER_AGENT
69 IG_SIG_KEY = Constants.IG_SIG_KEY
70 IG_CAPABILITIES = Constants.IG_CAPABILITIES
71 SIG_KEY_VERSION = Constants.SIG_KEY_VERSION
72 APPLICATION_ID = Constants.APPLICATION_ID
73
74 def __init__(self, username, password, **kwargs):
75 """
76
77 :param username: Login username
78 :param password: Login password
79 :param kwargs: See below
80
81 :Keyword Arguments:
82 - **auto_patch**: Patch the api objects to match the public API. Default: False
83 - **drop_incompat_key**: Remove api object keys that is not in the public API. Default: False
84 - **timeout**: Timeout interval in seconds. Default: 15
85 - **api_url**: Override the default api url base
86 - **cookie**: Saved cookie string from a previous session
87 - **settings**: A dict of settings from a previous session
88 - **on_login**: Callback after successful login
89 - **proxy**: Specify a proxy ex: 'http://127.0.0.1:8888' (ALPHA)
90 - **proxy_handler**: Specify your own proxy handler
91 :return:
92 """
93 self.username = username
94 self.password = password
95 self.auto_patch = kwargs.pop('auto_patch', False)
96 self.drop_incompat_keys = kwargs.pop('drop_incompat_keys', False)
97 self.api_url = kwargs.pop('api_url', None) or self.API_URL
98 self.timeout = kwargs.pop('timeout', 15)
99 self.on_login = kwargs.pop('on_login', None)
100 self.logger = logger
101
102 user_settings = kwargs.pop('settings', None) or {}
103 self.uuid = (
104 kwargs.pop('guid', None) or kwargs.pop('uuid', None) or
105 user_settings.get('uuid') or self.generate_uuid(False))
106 self.device_id = (
107 kwargs.pop('device_id', None) or user_settings.get('device_id') or
108 self.generate_deviceid())
109 # application session ID
110 self.session_id = (
111 kwargs.pop('session_id', None) or user_settings.get('session_id') or
112 self.generate_uuid(False))
113 self.signature_key = (
114 kwargs.pop('signature_key', None) or user_settings.get('signature_key') or
115 self.IG_SIG_KEY)
116 self.key_version = (
117 kwargs.pop('key_version', None) or user_settings.get('key_version') or
118 self.SIG_KEY_VERSION)
119 self.ig_capabilities = (
120 kwargs.pop('ig_capabilities', None) or user_settings.get('ig_capabilities') or
121 self.IG_CAPABILITIES)
122 self.application_id = (
123 kwargs.pop('application_id', None) or user_settings.get('application_id') or
124 self.APPLICATION_ID)
125
126 # to maintain backward compat for user_agent kwarg
127 custom_ua = kwargs.pop('user_agent', '') or user_settings.get('user_agent')
128 if custom_ua:
129 self.user_agent = custom_ua
130 else:
131 self.app_version = (
132 kwargs.pop('app_version', None) or user_settings.get('app_version') or
133 Constants.APP_VERSION)
134 self.android_release = (
135 kwargs.pop('android_release', None) or user_settings.get('android_release') or
136 Constants.ANDROID_RELEASE)
137 self.android_version = int(
138 kwargs.pop('android_version', None) or user_settings.get('android_version') or
139 Constants.ANDROID_VERSION)
140 self.phone_manufacturer = (
141 kwargs.pop('phone_manufacturer', None) or user_settings.get('phone_manufacturer') or
142 Constants.PHONE_MANUFACTURER)
143 self.phone_device = (
144 kwargs.pop('phone_device', None) or user_settings.get('phone_device') or
145 Constants.PHONE_DEVICE)
146 self.phone_model = (
147 kwargs.pop('phone_model', None) or user_settings.get('phone_model') or
148 Constants.PHONE_MODEL)
149 self.phone_dpi = (
150 kwargs.pop('phone_dpi', None) or user_settings.get('phone_dpi') or
151 Constants.PHONE_DPI)
152 self.phone_resolution = (
153 kwargs.pop('phone_resolution', None) or user_settings.get('phone_resolution') or
154 Constants.PHONE_RESOLUTION)
155 self.phone_chipset = (
156 kwargs.pop('phone_chipset', None) or user_settings.get('phone_chipset') or
157 Constants.PHONE_CHIPSET)
158 self.version_code = (
159 kwargs.pop('version_code', None) or user_settings.get('version_code') or
160 Constants.VERSION_CODE)
161
162 cookie_string = kwargs.pop('cookie', None) or user_settings.get('cookie')
163 cookie_jar = ClientCookieJar(cookie_string=cookie_string)
164 if cookie_string and cookie_jar.auth_expires and int(time.time()) >= cookie_jar.auth_expires:
165 raise ClientCookieExpiredError('Cookie expired at {0!s}'.format(cookie_jar.auth_expires))
166 cookie_handler = compat_urllib_request.HTTPCookieProcessor(cookie_jar)
167
168 proxy_handler = kwargs.pop('proxy_handler', None)
169 if not proxy_handler:
170 proxy = kwargs.pop('proxy', None)
171 if proxy:
172 warnings.warn('Proxy support is alpha.', UserWarning)
173 parsed_url = compat_urllib_parse_urlparse(proxy)
174 if parsed_url.netloc and parsed_url.scheme:
175 proxy_address = '{0!s}://{1!s}'.format(parsed_url.scheme, parsed_url.netloc)
176 proxy_handler = compat_urllib_request.ProxyHandler({'https': proxy_address})
177 else:
178 raise ValueError('Invalid proxy argument: {0!s}'.format(proxy))
179 handlers = []
180 if proxy_handler:
181 handlers.append(proxy_handler)
182
183 # Allow user to override custom ssl context where possible
184 custom_ssl_context = kwargs.pop('custom_ssl_context', None)
185 try:
186 https_handler = compat_urllib_request.HTTPSHandler(context=custom_ssl_context)
187 except TypeError:
188 # py version < 2.7.9
189 https_handler = compat_urllib_request.HTTPSHandler()
190
191 handlers.extend([
192 compat_urllib_request.HTTPHandler(),
193 https_handler,
194 cookie_handler])
195 opener = compat_urllib_request.build_opener(*handlers)
196 opener.cookie_jar = cookie_jar
197 self.opener = opener
198
199 # ad_id must be initialised after cookie_jar/opener because
200 # it relies on self.authenticated_user_name
201 self.ad_id = (
202 kwargs.pop('ad_id', None) or user_settings.get('ad_id') or
203 self.generate_adid())
204
205 if not cookie_string: # [TODO] There's probably a better way than to depend on cookie_string
206 if not self.username or not self.password:
207 raise ClientLoginRequiredError('login_required', code=400)
208 self.login()
209
210 self.logger.debug('USERAGENT: {0!s}'.format(self.user_agent))
211 super(Client, self).__init__()
212
213 @property
214 def settings(self):
215 """Helper property that extracts the settings that you should cache
216 in addition to username and password."""
217 return {
218 'uuid': self.uuid,
219 'device_id': self.device_id,
220 'ad_id': self.ad_id,
221 'session_id': self.session_id,
222 'cookie': self.cookie_jar.dump(),
223 'created_ts': int(time.time())
224 }
225
226 @property
227 def user_agent(self):
228 """Returns the useragent string that the client is currently using."""
229 return Constants.USER_AGENT_FORMAT.format(**{
230 'app_version': self.app_version,
231 'android_version': self.android_version,
232 'android_release': self.android_release,
233 'brand': self.phone_manufacturer,
234 'device': self.phone_device,
235 'model': self.phone_model,
236 'dpi': self.phone_dpi,
237 'resolution': self.phone_resolution,
238 'chipset': self.phone_chipset,
239 'version_code': self.version_code})
240
241 @user_agent.setter
242 def user_agent(self, value):
243 """Override the useragent string with your own"""
244 mobj = re.search(Constants.USER_AGENT_EXPRESSION, value)
245 if not mobj:
246 raise ValueError('User-agent specified does not fit format required: {0!s}'.format(
247 Constants.USER_AGENT_EXPRESSION))
248 self.app_version = mobj.group('app_version')
249 self.android_release = mobj.group('android_release')
250 self.android_version = int(mobj.group('android_version'))
251 self.phone_manufacturer = mobj.group('manufacturer')
252 self.phone_device = mobj.group('device')
253 self.phone_model = mobj.group('model')
254 self.phone_dpi = mobj.group('dpi')
255 self.phone_resolution = mobj.group('resolution')
256 self.phone_chipset = mobj.group('chipset')
257 self.version_code = mobj.group('version_code')
258
259 @staticmethod
260 def generate_useragent(**kwargs):
261 """
262 Helper method to generate a useragent string based on device parameters
263
264 :param kwargs:
265 - **app_version**
266 - **android_version**
267 - **android_release**
268 - **brand**
269 - **device**
270 - **model**
271 - **dpi**
272 - **resolution**
273 - **chipset**
274 :return: A compatible user agent string
275 """
276 return Constants.USER_AGENT_FORMAT.format(**{
277 'app_version': kwargs.pop('app_version', None) or Constants.APP_VERSION,
278 'android_version': int(kwargs.pop('android_version', None) or Constants.ANDROID_VERSION),
279 'android_release': kwargs.pop('android_release', None) or Constants.ANDROID_RELEASE,
280 'brand': kwargs.pop('phone_manufacturer', None) or Constants.PHONE_MANUFACTURER,
281 'device': kwargs.pop('phone_device', None) or Constants.PHONE_DEVICE,
282 'model': kwargs.pop('phone_model', None) or Constants.PHONE_MODEL,
283 'dpi': kwargs.pop('phone_dpi', None) or Constants.PHONE_DPI,
284 'resolution': kwargs.pop('phone_resolution', None) or Constants.PHONE_RESOLUTION,
285 'chipset': kwargs.pop('phone_chipset', None) or Constants.PHONE_CHIPSET,
286 'version_code': kwargs.pop('version_code', None) or Constants.VERSION_CODE})
287
288 @staticmethod
289 def validate_useragent(value):
290 """
291 Helper method to validate a useragent string for format correctness
292
293 :param value:
294 :return:
295 """
296 mobj = re.search(Constants.USER_AGENT_EXPRESSION, value)
297 if not mobj:
298 raise ValueError(
299 'User-agent specified does not fit format required: {0!s}'.format(
300 Constants.USER_AGENT_EXPRESSION))
301 parse_params = {
302 'app_version': mobj.group('app_version'),
303 'android_version': int(mobj.group('android_version')),
304 'android_release': mobj.group('android_release'),
305 'brand': mobj.group('manufacturer'),
306 'device': mobj.group('device'),
307 'model': mobj.group('model'),
308 'dpi': mobj.group('dpi'),
309 'resolution': mobj.group('resolution'),
310 'chipset': mobj.group('chipset'),
311 'version_code': mobj.group('version_code'),
312 }
313 return {
314 'user_agent': Constants.USER_AGENT_FORMAT.format(**parse_params),
315 'parsed_params': parse_params
316 }
317
318 def get_cookie_value(self, key, domain=''):
319 now = int(time.time())
320 eternity = now + 100 * 365 * 24 * 60 * 60 # future date for non-expiring cookies
321 if not domain:
322 domain = compat_urllib_parse_urlparse(self.API_URL).netloc
323
324 for cookie in sorted(
325 self.cookie_jar, key=lambda c: c.expires or eternity, reverse=True):
326 # don't return expired cookie
327 if cookie.expires and cookie.expires < now:
328 continue
329 # cookie domain may be i.instagram.com or .instagram.com
330 cookie_domain = cookie.domain
331 # simple domain matching
332 if cookie_domain.startswith('.'):
333 cookie_domain = cookie_domain[1:]
334 if not domain.endswith(cookie_domain):
335 continue
336
337 if cookie.name.lower() == key.lower():
338 return cookie.value
339
340 return None
341
342 @property
343 def csrftoken(self):
344 """The client's current csrf token"""
345 return self.get_cookie_value('csrftoken')
346
347 @property
348 def token(self):
349 """For compatibility. Equivalent to :meth:`csrftoken`"""
350 return self.csrftoken
351
352 @property
353 def authenticated_user_id(self):
354 """The current authenticated user id"""
355 return self.get_cookie_value('ds_user_id')
356
357 @property
358 def authenticated_user_name(self):
359 """The current authenticated user name"""
360 return self.get_cookie_value('ds_user')
361
362 @property
363 def phone_id(self):
364 """Current phone ID. For use in certain functions."""
365 return self.generate_uuid(return_hex=False, seed=self.device_id)
366
367 @property
368 def timezone_offset(self):
369 """Timezone offset in seconds. For use in certain functions."""
370 return int(round((datetime.now() - datetime.utcnow()).total_seconds()))
371
372 @property
373 def rank_token(self):
374 if not self.authenticated_user_id:
375 return None
376 return '{0!s}_{1!s}'.format(self.authenticated_user_id, self.uuid)
377
378 @property
379 def authenticated_params(self):
380 return {
381 '_csrftoken': self.csrftoken,
382 '_uuid': self.uuid,
383 '_uid': self.authenticated_user_id
384 }
385
386 @property
387 def cookie_jar(self):
388 """The client's cookiejar instance."""
389 return self.opener.cookie_jar
390
391 @property
392 def default_headers(self):
393 return {
394 'User-Agent': self.user_agent,
395 'Connection': 'close',
396 'Accept': '*/*',
397 'Accept-Language': 'en-US',
398 'Accept-Encoding': 'gzip, deflate',
399 'X-IG-Capabilities': self.ig_capabilities,
400 'X-IG-Connection-Type': 'WIFI',
401 'X-IG-Connection-Speed': '{0:d}kbps'.format(random.randint(1000, 5000)),
402 'X-IG-App-ID': self.application_id,
403 'X-IG-Bandwidth-Speed-KBPS': '-1.000',
404 'X-IG-Bandwidth-TotalBytes-B': '0',
405 'X-IG-Bandwidth-TotalTime-MS': '0',
406 'X-FB-HTTP-Engine': Constants.FB_HTTP_ENGINE,
407 }
408
409 @property
410 def radio_type(self):
411 """For use in certain endpoints"""
412 return 'wifi-none'
413
414 def _generate_signature(self, data):
415 """
416 Generates the signature for a data string
417
418 :param data: content to be signed
419 :return:
420 """
421 return hmac.new(
422 self.signature_key.encode('ascii'), data.encode('ascii'),
423 digestmod=hashlib.sha256).hexdigest()
424
425 @classmethod
426 def generate_uuid(cls, return_hex=False, seed=None):
427 """
428 Generate uuid
429
430 :param return_hex: Return in hex format
431 :param seed: Seed value to generate a consistent uuid
432 :return:
433 """
434 if seed:
435 m = hashlib.md5()
436 m.update(seed.encode('utf-8'))
437 new_uuid = uuid.UUID(m.hexdigest())
438 else:
439 new_uuid = uuid.uuid1()
440 if return_hex:
441 return new_uuid.hex
442 return str(new_uuid)
443
444 @classmethod
445 def generate_deviceid(cls, seed=None):
446 """
447 Generate an android device ID
448
449 :param seed: Seed value to generate a consistent device ID
450 :return:
451 """
452 return 'android-{0!s}'.format(cls.generate_uuid(True, seed)[:16])
453
454 def generate_adid(self, seed=None):
455 """
456 Generate an Advertising ID based on the login username since
457 the Google Ad ID is a personally identifying but resettable ID.
458
459 :return:
460 """
461 modified_seed = seed or self.authenticated_user_name or self.username
462 if modified_seed:
463 # Do some trivial mangling of original seed
464 sha2 = hashlib.sha256()
465 sha2.update(modified_seed.encode('utf-8'))
466 modified_seed = sha2.hexdigest()
467 return self.generate_uuid(False, modified_seed)
468
469 @staticmethod
470 def _read_response(response):
471 """
472 Extract the response body from a http response.
473
474 :param response:
475 :return:
476 """
477 if response.info().get('Content-Encoding') == 'gzip':
478 buf = BytesIO(response.read())
479 res = gzip.GzipFile(fileobj=buf).read().decode('utf8')
480 else:
481 res = response.read().decode('utf8')
482 return res
483
484 def _call_api(self, endpoint, params=None, query=None, return_response=False, unsigned=False, version='v1'):
485 """
486 Calls the private api.
487
488 :param endpoint: endpoint path that should end with '/', example 'discover/explore/'
489 :param params: POST parameters
490 :param query: GET url query parameters
491 :param return_response: return the response instead of the parsed json object
492 :param unsigned: use post params as-is without signing
493 :param version: for the versioned api base url. Default 'v1'.
494 :return:
495 """
496 url = '{0}{1}'.format(self.api_url.format(version=version), endpoint)
497 if query:
498 url += ('?' if '?' not in endpoint else '&') + compat_urllib_parse.urlencode(query)
499
500 headers = self.default_headers
501 data = None
502 if params or params == '':
503 headers['Content-type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
504 if params == '': # force post if empty string
505 data = ''.encode('ascii')
506 else:
507 if not unsigned:
508 json_params = json.dumps(params, separators=(',', ':'))
509 hash_sig = self._generate_signature(json_params)
510 post_params = {
511 'ig_sig_key_version': self.key_version,
512 'signed_body': hash_sig + '.' + json_params
513 }
514 else:
515 # direct form post
516 post_params = params
517 data = compat_urllib_parse.urlencode(post_params).encode('ascii')
518
519 req = compat_urllib_request.Request(url, data, headers=headers)
520 try:
521 self.logger.debug('REQUEST: {0!s} {1!s}'.format(url, req.get_method()))
522 self.logger.debug('DATA: {0!s}'.format(data))
523 response = self.opener.open(req, timeout=self.timeout)
524 except compat_urllib_error.HTTPError as e:
525 error_response = self._read_response(e)
526 self.logger.debug('RESPONSE: {0:d} {1!s}'.format(e.code, error_response))
527 ErrorHandler.process(e, error_response)
528
529 except (SSLError, timeout, SocketError,
530 compat_urllib_error.URLError, # URLError is base of HTTPError
531 compat_http_client.HTTPException,
532 ConnectionError) as connection_error:
533 raise ClientConnectionError('{} {}'.format(
534 connection_error.__class__.__name__, str(connection_error)))
535
536 if return_response:
537 return response
538
539 response_content = self._read_response(response)
540 self.logger.debug('RESPONSE: {0:d} {1!s}'.format(response.code, response_content))
541 json_response = json.loads(response_content)
542
543 if json_response.get('message', '') == 'login_required':
544 raise ClientLoginRequiredError(
545 json_response.get('message'), code=response.code,
546 error_response=json.dumps(json_response))
547
548 # not from oembed or an ok response
549 if not json_response.get('provider_url') and json_response.get('status', '') != 'ok':
550 raise ClientError(
551 json_response.get('message', 'Unknown error'), code=response.code,
552 error_response=json.dumps(json_response))
553
554 return json_response
555