From a92b5e820f3c075e8a20e36ffb6055b7c0af8b0f Mon Sep 17 00:00:00 2001 From: Erik Lopez Date: Fri, 15 Nov 2019 14:33:03 +0900 Subject: [PATCH] Add 'Comments' endpoint --- README.md | 49 +++++++++++++++------ instpector/apis/exceptions.py | 3 ++ instpector/apis/instagram/__init__.py | 4 +- instpector/apis/instagram/base_graph_ql.py | 9 ++-- instpector/apis/instagram/comments.py | 36 ++++++++++++++++ instpector/apis/instagram/definitions.py | 6 ++- instpector/apis/instagram/like_graph_ql.py | 28 ++++++++++++ instpector/apis/instagram/parser.py | 50 +++++++++++++++++++--- instpector/apis/instagram/timeline.py | 27 +++--------- instpector/endpoints.py | 1 + setup.py | 2 +- 11 files changed, 169 insertions(+), 46 deletions(-) create mode 100644 instpector/apis/instagram/comments.py create mode 100644 instpector/apis/instagram/like_graph_ql.py diff --git a/README.md b/README.md index 94c4115..3902dc9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ instpector.logout() ``` ## Using 2FA -For login in using two-factor authentication, generate your 2fa key once on Instagram's app and provide the code when logging in with `instpector`. The following example uses `pytop` to demonstrate the usage: +For login in using two-factor authentication, generate your 2fa key once on the Instagram's app and provide the code when logging in with `instpector`. The following example uses `pytop` library to demonstrate the usage: ```python from pyotp import TOTP @@ -51,6 +51,7 @@ Check out more examples [here](https://github.com/niuware/instpector/tree/master - Followers - Following - Timeline +- Comments - Profile - Story Reel - Story @@ -65,7 +66,7 @@ More to come |Method|Details| |---|---| -|login(user, password, two_factor_code=None)|Login to an Instagram account. If your account is 2FA protected provide the 2FA code as in the [provided example](https://github.com/niuware/instpector/blob/master/examples/two_factor_auth.py).| +|login(user: `string`, password: `string`, two_factor_code: `string` = None)|Login to an Instagram account. If your account is 2FA protected provide the 2FA code as in the [provided example](https://github.com/niuware/instpector/blob/master/examples/two_factor_auth.py).| |logout()|Logouts from an Instagram account| |session()|Returns the current session used by `instpector`| @@ -73,7 +74,7 @@ More to come |Method|Details| |---|---| -|create(endpoint_name, instpector_instance)|Creates and returns an endpoint instance based on the provided name. Available endpoint names are: `"followers"`, `"following"`, `"profile"`, `"timeline"`, `"story_reel"` and `"story"`| +|create(endpoint_name: `string`, instpector_instance: `Instpector`)|Creates and returns an endpoint instance based on the provided name. Available endpoint names are: `"followers"`, `"following"`, `"profile"`, `"timeline"`, `"comments"` `"story_reel"` and `"story"`| ## Endpoints @@ -83,7 +84,7 @@ Gets the profile of any public or friend user account. |Method|Details| |---|---| -|of_user(username)|Returns a `TProfile` instance for the provided username.| +|of_user(username: `string`)|Returns a `TProfile` instance for the provided username.| ### Followers @@ -91,7 +92,7 @@ Endpoint for accessing the follower list of any public or friend user account. |Method|Details| |---|---| -|of_user(user_id)|Returns a generator of `TUser` instances with all followers. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| +|of_user(user_id: `string`)|Returns a generator of `TUser` instances with all followers. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| ### Following @@ -99,7 +100,7 @@ Endpoint for accessing the followees list of any public or friend user account. |Method|Details| |---|---| -|of_user(user_id)|Returns a generator of `TUser` instances with all followees. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| +|of_user(user_id: `string`)|Returns a generator of `TUser` instances with all followees. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| ### Timeline @@ -107,10 +108,21 @@ Endpoint for accessing the timeline of any public or friend account. |Method|Details| |---|---| -|of_user(user_id)|Returns a generator of `TTimelinePost` instances with all timeline posts. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| -|download(timeline_post, only_image=False, low_quality=False)|Downloads and save the available resources (image and video) for the provided `TTimelinePost`. The file name convention is `ownerid_resourceid.extension` and saved in the execution directory. If `low_quality` is `True` the resource will be the downloaded with the lowest size available (only for image). If `only_image` is `True` a video file resource won't be downloaded.| -|like(timeline_post)|Likes a timeline post (`TTimelinePost`).| -|unlike(timeline_post)|Unlikes a timeline post (`TTimelinePost`).| +|of_user(user_id: `string`)|Returns a generator of `TTimelinePost` instances with all timeline posts. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| +|download(timeline_post: `TTimelinePost`, only_image: `bool` = False, low_quality: `bool` = False)|Downloads and save the available resources (image and video) for the provided `TTimelinePost`. The file name convention is `ownerid_resourceid.extension` and saved in the execution directory. If `low_quality` is `True` the resource will be the downloaded with the lowest size available (only for image). If `only_image` is `True` a video file resource won't be downloaded.| +|like(timeline_post: `TTimelinePost`)|Likes a timeline post.| +|unlike(timeline_post: `TTimelinePost`)|Unlikes a timeline post.| + +### Comments + +Endponint for accessing comments and threaded comments of any public or friends post or comment. + +|Method|Details| +|---|---| +|of_post(timeline_post: `TTimelinePost`)|Returns a generator of `TComment` instances with all post comments.| +|of_comment(comment: `TComment`)|Returns a generator of `TComment` instances with all threaded comments of a comment.| +|like(comment: `TComment`)|Likes a comment.| +|unlike(comment: `TComment`)|Unlikes a comment.| ### StoryReel @@ -118,8 +130,8 @@ Endpoint for accessing the story reel (stories) of any public or friend user acc |Method|Details| |---|---| -|of_user(user_id)|Returns a generator of `TStoryReelItem` instances with all stories. Note the method receives a user id and not a username. To get the user id use the `Profile` endpoint.| -|download(story_item, only_image=False, low_quality=False)|Downloads and save the available resources (image and video) for the provided `TStoryReelItem`. The file name convention is `ownerid_resourceid.extension` and saved in the execution directory. If `low_quality` is `True` the resource will be the downloaded with the lowest size available. If `only_image` is `True` a video file resource won't be downloaded.| +|of_user(user_id: `string`)|Returns a generator of `TStoryReelItem` instances with all stories. Note the method receives a user id and not a username. To get a user id use the `Profile` endpoint.| +|download(story_item: `TStoryReelItem`, only_image: `bool` = False, low_quality: `bool` = False)|Downloads and save the available resources (image and video) for the provided `TStoryReelItem`. The file name convention is `ownerid_resourceid.extension` and saved in the execution directory. If `low_quality` is `True` the resource will be the downloaded with the lowest size available. If `only_image` is `True` a video file resource won't be downloaded.| ### Story @@ -127,7 +139,7 @@ Endpoint for accessing the story details of a story reel item. This endpoint is |Method|Details| |---|---| -|viewers_for(story_id)|Returns a generator of `TStoryViewer` instances with all viewers of the provided story id.| +|viewers_for(story_id: `string`)|Returns a generator of `TStoryViewer` instances with all viewers of the provided story id.| ## Types @@ -163,6 +175,17 @@ Endpoint for accessing the story details of a story reel item. This endpoint is |display_resources|`list`|A list of image URLs associated with the post| |video_url|`string`|The video URL (if available) associated with the post| +### TComment +|Field|Type|Details| +|---|---|---| +|id|`string`|The Instagram Id of the comment| +|text|`string`|The comment text| +|username|`string`|The author's username| +|timestamp|`integer`|The timestamp of the comment| +|viewer_has_liked|`bool`|A flag to know if the viewer liked the comment| +|liked_count|`integer`|The like count of the comment| +|thread_count|`integer` \| `None`|The comment's thread comments count. This value is `None` if the instance is a threaded comment.| + ### TStoryReelItem |Field|Type|Details| |---|---|---| diff --git a/instpector/apis/exceptions.py b/instpector/apis/exceptions.py index 4138789..e1e4292 100644 --- a/instpector/apis/exceptions.py +++ b/instpector/apis/exceptions.py @@ -12,3 +12,6 @@ class ParseDataException(InstpectorException): class NotImplementedException(InstpectorException): pass + +class NoDataException(InstpectorException): + pass diff --git a/instpector/apis/instagram/__init__.py b/instpector/apis/instagram/__init__.py index 6107542..4f01917 100644 --- a/instpector/apis/instagram/__init__.py +++ b/instpector/apis/instagram/__init__.py @@ -6,6 +6,7 @@ from .timeline import Timeline from .story_reel import StoryReel from .story import Story +from .comments import Comments __all__ = ["Authenticate", "Followers", @@ -14,5 +15,6 @@ "Profile", "Timeline", "StoryReel", - "Story" + "Story", + "Comments" ] diff --git a/instpector/apis/instagram/base_graph_ql.py b/instpector/apis/instagram/base_graph_ql.py index 48edb1e..d3ce3e2 100644 --- a/instpector/apis/instagram/base_graph_ql.py +++ b/instpector/apis/instagram/base_graph_ql.py @@ -1,5 +1,5 @@ import json -from ..exceptions import ParseDataException +from ..exceptions import ParseDataException, NoDataException from .base_api import BaseApi from .parser import Parser from .definitions import TPageInfo @@ -22,8 +22,11 @@ def _loop(self, query_hash, variables, **parser_callbacks): if data: page_info = Parser.page_info(data, parser_callbacks.get("page_info_parser"), parser_callbacks.get("page_info_parser_path")) - for result in parser_callbacks.get("data_parser")(data): - yield result + try: + for result in parser_callbacks.get("data_parser")(data): + yield result + except NoDataException: + return def _get_partial_data(self, query_hash, variables, end_cursor, cursor_name): cursor_name = cursor_name or "after" diff --git a/instpector/apis/instagram/comments.py b/instpector/apis/instagram/comments.py new file mode 100644 index 0000000..554f8a4 --- /dev/null +++ b/instpector/apis/instagram/comments.py @@ -0,0 +1,36 @@ +from .like_graph_ql import LikeGraphQL +from .parser import Parser + + +class Comments(LikeGraphQL): + + def __init__(self, instance): + super().__init__(instance, "/web/comments/{action}/{id}/") + + def of_post(self, timeline_post): + shortcode = getattr(timeline_post, "shortcode", None) + if shortcode is None: + return [] + variables = { + "shortcode": shortcode, + "first": self.DEFAULT_EDGE_COUNT + } + return self._loop("97b41c52301f77ce508f55e66d17620e", + variables=variables, + page_info_parser="edge_media_to_parent_comment", + page_info_parser_path="shortcode_media", + data_parser=Parser.parent_comments) + + def of_comment(self, comment): + comment_id = getattr(comment, "id", None) + if comment_id is None: + return [] + variables = { + "comment_id": comment_id, + "first": 6 + } + return self._loop("51fdd02b67508306ad4484ff574a0b62", + variables=variables, + page_info_parser="edge_threaded_comments", + page_info_parser_path="comment", + data_parser=Parser.threaded_comments) diff --git a/instpector/apis/instagram/definitions.py b/instpector/apis/instagram/definitions.py index 8bdd722..9ec7a20 100644 --- a/instpector/apis/instagram/definitions.py +++ b/instpector/apis/instagram/definitions.py @@ -9,7 +9,7 @@ )) TTimelinePost = namedtuple("TTimelinePost", ( - "id owner timestamp is_video like_count comment_count display_resources video_url" + "id owner timestamp is_video like_count comment_count display_resources video_url shortcode" )) TStoryReelItem = namedtuple("TStoryReelItem", ( @@ -17,3 +17,7 @@ )) TStoryViewer = namedtuple("TStoryViewer", "id username") + +TComment = namedtuple("TComment", ( + "id text timestamp username viewer_has_liked liked_count, thread_count" +)) diff --git a/instpector/apis/instagram/like_graph_ql.py b/instpector/apis/instagram/like_graph_ql.py new file mode 100644 index 0000000..fd07596 --- /dev/null +++ b/instpector/apis/instagram/like_graph_ql.py @@ -0,0 +1,28 @@ +from .base_graph_ql import BaseGraphQL + + +class LikeGraphQL(BaseGraphQL): + + def __init__(self, instance, url): + self._url = url + super().__init__(instance) + + def _toggle_like(self, obj, action): + endpoint = 'like' if action == 'like' else 'unlike' + obj_id = getattr(obj, "id", None) + if obj_id is None: + return False + response = self.post(self._url.format(action=endpoint, id=obj_id), + use_auth=True, + headers={ + "Content-Type": "application/x-www-form-urlencoded" + }) + if response and response.get("status") == "ok": + return True + return False + + def unlike(self, obj): + return self._toggle_like(obj, 'unlike') + + def like(self, obj): + return self._toggle_like(obj, 'like') diff --git a/instpector/apis/instagram/parser.py b/instpector/apis/instagram/parser.py index 63bb1a9..4552806 100644 --- a/instpector/apis/instagram/parser.py +++ b/instpector/apis/instagram/parser.py @@ -1,4 +1,6 @@ -from .definitions import TUser, TPageInfo, TProfile, TTimelinePost, TStoryReelItem, TStoryViewer +from .definitions import TUser, TPageInfo, TProfile, TTimelinePost, \ + TStoryReelItem, TStoryViewer, TComment +from ..exceptions import NoDataException class Parser: @@ -66,7 +68,8 @@ def timeline(data): like_count=likes.get("count", 0), comment_count=comments.get("count", 0), display_resources=list(map(lambda res: res.get("src"), display_resources)), - video_url=node.get("video_url") + video_url=node.get("video_url"), + shortcode=node.get("shortcode") ) yield post @@ -76,7 +79,10 @@ def _get_edges(data, endpoint, d_path=None): data_root = data.get("data") or {} root = data_root.get(data_path) or {} endpoint_root = root.get(endpoint) or {} - return endpoint_root.get("edges") or [] + edges = endpoint_root.get("edges") + if not edges: + raise NoDataException + return edges @staticmethod def story_reel(data): @@ -104,11 +110,45 @@ def story_reel(data): @staticmethod def story(data): - edges = Parser._get_edges(data, "edge_story_media_viewers", "media") - for edge in edges: + for edge in Parser._get_edges(data, "edge_story_media_viewers", "media"): node = edge.get("node") or {} viewer = TStoryViewer( id=node.get("id"), username=node.get("username") ) yield viewer + + @staticmethod + def parent_comments(data): + for edge in Parser._get_edges(data, "edge_media_to_parent_comment", "shortcode_media"): + node = edge.get("node") or {} + owner = node.get("owner") or {} + edge_liked = node.get("edge_liked_by") or {} + edge_threaded = node.get("edge_threaded_comments") or {} + comment = TComment( + id=node.get("id", ""), + text=node.get("text", ""), + timestamp=node.get("created_at"), + username=owner.get("username", ""), + viewer_has_liked=node.get("viewer_has_liked", False), + liked_count=edge_liked.get("count", 0), + thread_count=edge_threaded.get("count", 0) + ) + yield comment + + @staticmethod + def threaded_comments(data): + for edge in Parser._get_edges(data, "edge_threaded_comments", "comment"): + node = edge.get("node") or {} + owner = node.get("owner") or {} + edge_liked = node.get("edge_liked_by") or {} + comment = TComment( + id=node.get("id", ""), + text=node.get("text", ""), + timestamp=node.get("created_at"), + username=owner.get("username", ""), + viewer_has_liked=node.get("viewer_has_liked", False), + liked_count=edge_liked.get("count", 0), + thread_count=None + ) + yield comment diff --git a/instpector/apis/instagram/timeline.py b/instpector/apis/instagram/timeline.py index efb036f..3e59ac1 100644 --- a/instpector/apis/instagram/timeline.py +++ b/instpector/apis/instagram/timeline.py @@ -1,8 +1,11 @@ -from .base_graph_ql import BaseGraphQL +from .like_graph_ql import LikeGraphQL from .parser import Parser -class Timeline(BaseGraphQL): +class Timeline(LikeGraphQL): + + def __init__(self, instance): + super().__init__(instance, "/web/likes/{id}/{action}/") def of_user(self, user_id): variables = { @@ -20,23 +23,3 @@ def download(self, timeline_post, only_image=False, low_quality=False): if timeline_post.video_url: file_name = f"{timeline_post.owner}_{timeline_post.id}.mp4" super().download_file(timeline_post.video_url, file_name) - - def _toggle_like(self, timeline_post, action): - endpoint = 'like' if action == 'like' else 'unlike' - post_id = getattr(timeline_post, "id", None) - if post_id is None: - return False - response = self.post("/web/likes/" + post_id + "/" + endpoint + "/", - use_auth=True, - headers={ - "Content-Type": "application/x-www-form-urlencoded" - }) - if response and response.get("status") == "ok": - return True - return False - - def unlike(self, timeline_post): - return self._toggle_like(timeline_post, 'unlike') - - def like(self, timeline_post): - return self._toggle_like(timeline_post, 'like') diff --git a/instpector/endpoints.py b/instpector/endpoints.py index 62c54a2..aca9313 100644 --- a/instpector/endpoints.py +++ b/instpector/endpoints.py @@ -9,3 +9,4 @@ factory.register_endpoint('timeline', instagram.Timeline) factory.register_endpoint('story_reel', instagram.StoryReel) factory.register_endpoint('story', instagram.Story) +factory.register_endpoint('comments', instagram.Comments) diff --git a/setup.py b/setup.py index 3d347fd..14f44db 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="instpector", - version="0.2.3", + version="0.2.4", description="A simple Instagram's web API library", author="Erik Lopez", long_description=README,