├── .gitignore ├── README.md ├── requirements.txt ├── utils ├── __init__.py └── comments.py ├── yt_auth.py └── yt_public.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.json 3 | *.csv 4 | # ignore pycache 5 | __pycache__ 6 | */__pycache__ 7 | *.pyc 8 | client_secret_* 9 | *.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### YouTube API (Python) 2 | This is a repository hosting the code examples demonstrated in the YouTube API tutorial on my youtube channel. 3 | - [YouTube Analytics API tutorial | YouTube](https://youtu.be/rcE3iL0URhU) 4 | 5 | ### Usage 6 | There are two main Python scripts: `yt_auth.py` and `yt_public.py`. 7 | 8 | 1. Install the dependencies `pip install -r requirements.txt` 9 | 10 | 1. Execute the scripts 11 | - To run `yt_auth`, you will need store your secrets as `client_secret.json` in the same directory. 12 | - 2b. To run `yt_public`, simply create a `.env` file and add a line to your API key: `API_KEY=` 13 | 14 | 1. Experiment! Modify `yt_public.py` to use one the ready-made functions (`search_result`, `comment_threads` etc) with a YouTube video ID / channel ID. The function names are pretty self-explanatory. Modify `yt_auth.py` to modify what gets returned, `startDate`, `endDate` etc. 15 | 16 | ```python 17 | pyscriptVidId = 'Qo8dXyKXyME' 18 | channelId = 'UCzIxc8Vg53_ewaRIk3shBug' 19 | 20 | # try one of the following (uncomment) 21 | # response = search_result("pyscript") 22 | response = channel_stats(channelId) 23 | # response = comment_threads(pyscriptVidId) 24 | print(response) 25 | ``` 26 | 27 | ### Example: 28 | ```bash 29 | # when returning channel_stats 30 | python yt_public.py 31 | 32 | # returns: 33 | {'kind': 'youtube#channelListResponse', 'etag': '5AtqmD44H4QfpPsqQ4Wnihwsngc', 'pageInfo': {'totalResults': 1, 'resultsPerPage': 5}, 'items': [{'kind': 'youtube#channel', 'etag': 'GB2ykHK9DVB-53aKbVA6YAUXNkE', 'id': 'UCzIxc8Vg53_ewaRIk3shBug', 'statistics': {'viewCount': '57498', 'subscriberCount': '961', 'hiddenSubscriberCount': False, 'videoCount': '59'}}]} 34 | ``` 35 | 36 | When returning `comment_threads`, it is recommended to return it with a `maxResults` parameter for videos with many comments: 37 | 38 | ```bash 39 | python yt_public.py 40 | {'kind': 'youtube#commentThreadListResponse', 'etag': 'Ic9b-4hLJVdF7UDLjD4AtQLB0Lw', 'nextPageToken': 'QURTSl9pM0hzMS1NSUo2c1lwNFloWWIwakpIanhtemlCdkRqYXJ6SlUtLVpVYVFwU2RyeE5RZ0dlX0cyLU0ya3JHWmlKdldRSVFXWU9HRQ==', 'pageInfo': {'totalResults': 20, 'resultsPerPage': 20}, 'items': [{'kind': 'youtube#commentThread', 'etag': 'TIrOdgUxY0GufmjsAq37cP98_wI', 'id': 'UgwyC-Lg8xTM-ZwDIMl4AaABAg', 'snippet': {'videoId': 'Qo8dXyKXyME', 'topLevelComment': {'kind': 'youtube#comment', 'etag': 'r-Y1IQx1b405LtcbYs4TnrJh3Jg', 'id': 'UgwyC-Lg8xTM-ZwDIMl4AaABAg', 'snippet': {'videoId': 'Qo8dXyKXyME', 'textDisplay': 'Ahuet guys, it was really impressive, obviously when he create repl tag and instead html-page he got realtime pyhton console!', 'textOriginal': 'Ahuet guys, it was really impressive, obviously when he create repl tag and instead html-page he got realtime pyhton console!', 'authorDisplayName': 'Белгородский Джедай', 'authorProfileImageUrl': 'https://yt3.ggpht.com/ytc/AKedOLS9oo0Gh5C7ENfUDlUz3yrFTwVNGN7SFLNauA=s48-c-k-c0x00ffffff-no-rj', 'authorChannelUrl': 'http://www.youtube.com/channel/UCDqaIEKwP63mCEa1DbdI8sg', 'authorChannelId': {'value': 'UCDqaIEKwP63mCEa1DbdI8sg'}, 'canRate': True, 'viewerRating': 'none', 'likeCount': 0, 'publishedAt': '2022-05-09T19:47:23Z', 'updatedAt': '2022-05-09T19:47:23Z'}}, 'canReply': True, 'totalReplyCount': 0, 'isPublic': True}}, {'kind': 'youtube#commentThread', 'etag': 'XuqkWlpnGIcVU4IpNssELzh6cGE', 'id': 'Ugx7GGYIl6G4UYf-PSR4AaABAg', 'snippet': {'videoId': 'Qo8dXyKXyME', 'topLevelComment': {'kind': 'youtube#comment', 'etag': 'v2NOrgq8b3kEr1kSpq6vQJ2CnhE', 'id': 'Ugx7GGYIl6G4UYf-PSR4AaABAg', 'snippet': {'videoId': 'Qo8dXyKXyME', 'textDisplay': 'Thank you very much.. Please add more more more project about pyscript.... From Indonesia🇮🇩', 'textOriginal': 'Thank you very much.. Please add more more more project about pyscript.... From Indonesia🇮🇩', 'authorDisplayName': 'Renni Ekaputri', 'authorProfileImageUrl': 'https://yt3.ggpht.com/d7JLKybsXKEnS6_TKQI8wNcGCxSOeiy92-bUPWKUsYjHU2s52wapXgSfKQiOa-3IpC14d9vA_w=s48-c-k-c0x00ffffff-no-rj', 'authorChannelUrl': 'http://www.youtube.com/channel/UCrsuj8xA6W_pUIo5yagHgYA', 'authorChannelId': {'value': 'UCrsuj8xA6W_pUIo5yagHgYA'}, 'canRate': True, 'viewerRating': 'none', 'likeCount': 0, 'publishedAt': '2022-05-09T18:45:03Z', 'updatedAt': '2022-05-09T18:45:03Z'}}, 'canReply': True, 'totalReplyCount': 1, 'isPublic': True}, 'replies': {'comments': [{'kind': 'youtube#comment', 'etag': 'wUSmUhfU3ox5KY0Sz0KsPnp6iB4', 'id': 'Ugx7GGYIl6G4UYf-PSR4AaABAg.9aolZGyDz3w9apYGzzghC4', 'snippet': {'videoId': 'Qo8dXyKXyME', 'textDisplay': 'Thank you Renni! Check out the other projects we build with pyscript too 😀

Part 1 (intro to PyScript): https://youtu.be/Qo8dXyKXyME
Part 2 (PyScript deployment): https://youtu.be/oH_rTTDjMvM
Part 3 (PyScript + Altair data dashboard): https://youtu.be/ugSBaOT0rVI
Part 4 (PyScript Guest Book app, CRUD): https://youtu.be/H6rNzQeryQo', 'textOriginal': 'Thank you Renni! Check out the other projects we build with pyscript too 😀\n\nPart 1 (intro to PyScript): https://youtu.be/Qo8dXyKXyME\nPart 2 (PyScript deployment): https://youtu.be/oH_rTTDjMvM\nPart 3 (PyScript + Altair data dashboard): https://youtu.be/ugSBaOT0rVI\nPart 4 (PyScript Guest Book app, CRUD): https://youtu.be/H6rNzQeryQo', 'parentId': 'Ugx7GGYIl6G4UYf-PSR4AaABAg', 'authorDisplayName': 'Samuel Chan', 'authorProfileImageUrl': 'https://yt3.ggpht.com/ytc/AKedOLRGpq_SJ3BGRNRqjdgeo8_STiwyFFheA6jdGbKr=s48-c-k-c0x00ffffff-no-rj', 'authorChannelUrl': 'http://www.youtube.com/channel/UCzIxc8Vg53_ewaRIk3shBug', 'authorChannelId': {'value': 'UCzIxc8Vg53_ewaRIk3shBug'}, 'canRate': True, 'viewerRating': 'none', 'likeCount': 0, 'publishedAt': '2022-05-10T01:59:27Z', 'updatedAt': '2022-05-10T01:59:27Z'}}]}}, {'kind': 'youtube#commentThread', 'etag': 'KmRQzOOglhz5HHoGpXcEri1TS28', 'id': 'Ugxo6DY0jEyzXfmye2R4AaABAg', 'snippet': {'videoId': 'Qo8dXyKXyME', 'topLevelComment': {'kind': 'youtube#comment', 'etag': '5cTRv8hTa0SnVvzegMjpA0zg4y8', 'id': 'Ugxo6DY0jEyzXfmye2R4AaABAg', 'snippet': {'videoId': 'Qo8dXyKXyME', 'textDisplay': 'Nice 👍
Does it take in .ipynb files?', 'textOriginal': 'Nice 👍\nDoes it take in .ipynb files?', 'authorDisplayName': 'phlorah', 'authorProfileImageUrl': 'https://yt3.ggpht.com/ytc/AKedOLTy0xRUqmV5i3c6MpvYdh33oTA7F8AMn4vXaBUGX-rPth6cSlW_P1bP3G1_VGmH=s48-c-k-c0x00ffffff-no-rj', 'authorChannelUrl': 'http://www.youtube.com/channel/UCq035cbe5f_l8b8KNN08NnA', 'authorChannelId': {'value': 'UCq035cbe5f_l8b8KNN08NnA'}}}}}]} 41 | ``` 42 | 43 | 44 | ```bash 45 | python yt_auth.py 46 | 47 | # returns: 48 | +------------+-------------------+-------+-------+-------------------+ 49 | | date | estMinutesWatched | views | likes | subscribersGained | 50 | +------------+-------------------+-------+-------+-------------------+ 51 | | 2022-05-01 | 756 | 194 | 9 | 11 | 52 | | 2022-05-02 | 886 | 248 | 4 | 8 | 53 | | 2022-05-03 | 589 | 247 | 7 | 1 | 54 | | 2022-05-04 | 4972 | 1346 | 50 | 22 | 55 | | 2022-05-05 | 9866 | 2689 | 62 | 55 | 56 | | 2022-05-06 | 10002 | 2759 | 98 | 92 | 57 | | 2022-05-07 | 10243 | 3200 | 96 | 78 | 58 | | 2022-05-08 | 12072 | 3600 | 108 | 100 | 59 | | 2022-05-09 | 11594 | 3746 | 116 | 92 | 60 | | 2022-05-10 | 8958 | 2762 | 66 | 69 | 61 | | 2022-05-11 | 7152 | 2156 | 33 | 51 | 62 | | 2022-05-12 | 8462 | 2269 | 63 | 61 | 63 | | 2022-05-13 | 7275 | 2480 | 64 | 52 | 64 | | 2022-05-14 | 5567 | 1702 | 36 | 33 | 65 | | 2022-05-15 | 4054 | 1218 | 22 | 32 | 66 | | 2022-05-16 | 3759 | 1138 | 15 | 29 | 67 | | 2022-05-17 | 3555 | 1040 | 16 | 21 | 68 | | 2022-05-18 | 3612 | 921 | 21 | 15 | 69 | | 2022-05-19 | 3134 | 899 | 19 | 15 | 70 | | 2022-05-20 | 2649 | 656 | 10 | 17 | 71 | | 2022-05-21 | 2569 | 678 | 14 | 12 | 72 | | ... ... | 73 | | 2023-04-17 | 2646 | 723 | 12 | 13 | 74 | | 2023-04-18 | 2098 | 660 | 8 | 15 | 75 | | 2023-04-19 | 2244 | 653 | 7 | 10 | 76 | | 2023-04-20 | 1897 | 589 | 13 | 17 | 77 | | 2023-04-21 | 2277 | 699 | 10 | 20 | 78 | | 2023-04-22 | 2694 | 734 | 11 | 20 | 79 | | 2023-04-23 | 2642 | 694 | 8 | 19 | 80 | | 2023-04-24 | 3174 | 839 | 20 | 24 | 81 | | 2023-04-25 | 3472 | 916 | 20 | 20 | 82 | | 2023-04-26 | 4346 | 1088 | 32 | 23 | 83 | | 2023-04-27 | 3838 | 964 | 14 | 18 | 84 | | 2023-04-28 | 3884 | 957 | 20 | 26 | 85 | +------------+-------------------+-------+-------+-------------------+ 86 | ``` 87 | 88 | It will also save the data into a csv file in the same directory. You can modify these behaviors, including the filename, the desired columns, and date ranges etc by modifying the `yt_auth.py` file. 89 | 90 | ### Next Steps 91 | - [ ] Add `argparse` 92 | - [ ] Refactor code to make them parameterized functions 93 | - [ ] Simple charting / tabular printing 94 | - [ ] Supports other methods from YouTube API 95 | - [ ] Provide cleaning utility for unpacking returned results -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cachetools==5.0.0 2 | certifi==2021.10.8 3 | charset-normalizer==2.0.12 4 | google-api-core==2.7.2 5 | google-api-python-client==2.46.0 6 | google-auth==2.6.6 7 | google-auth-httplib2==0.1.0 8 | google-auth-oauthlib==0.5.1 9 | googleapis-common-protos==1.56.0 10 | httplib2==0.20.4 11 | idna==3.3 12 | oauthlib==3.2.0 13 | protobuf==3.20.1 14 | pyasn1==0.4.8 15 | pyasn1-modules==0.2.8 16 | pyparsing==3.0.8 17 | python-dotenv==0.20.0 18 | requests==2.27.1 19 | requests-oauthlib==1.3.1 20 | rsa==4.8 21 | six==1.16.0 22 | tabulate==0.8.9 23 | uritemplate==4.1.1 24 | urllib3==1.26.9 25 | iteration_utilities 26 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onlyphantom/youtube_api_python/509f1cb0ebbebd7173f78b2133f32985c376fc51/utils/__init__.py -------------------------------------------------------------------------------- /utils/comments.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from datetime import datetime as dt 3 | 4 | comments = [] 5 | today = dt.today().strftime('%d-%m-%Y') 6 | 7 | def process_comments(response_items, csv_output=False): 8 | 9 | for res in response_items: 10 | 11 | # loop through the replies 12 | if 'replies' in res.keys(): 13 | for reply in res['replies']['comments']: 14 | comment = reply['snippet'] 15 | comment['commentId'] = reply['id'] 16 | comments.append(comment) 17 | else: 18 | comment = {} 19 | comment['snippet'] = res['snippet']['topLevelComment']['snippet'] 20 | comment['snippet']['parentId'] = None 21 | comment['snippet']['commentId'] = res['snippet']['topLevelComment']['id'] 22 | 23 | comments.append(comment['snippet']) 24 | 25 | if csv_output: 26 | make_csv(comments) 27 | 28 | print(f'Finished processing {len(comments)} comments.') 29 | return comments 30 | 31 | 32 | def make_csv(comments, channelID=None): 33 | header = comments[0].keys() 34 | 35 | if channelID: 36 | filename = f'comments_{channelID}_{today}.csv' 37 | else: 38 | filename = f'comments_{today}.csv' 39 | 40 | with open(filename, 'w', encoding='utf8', newline='') as f: 41 | writer = csv.DictWriter(f, fieldnames=header, extrasaction='ignore') 42 | writer.writeheader() 43 | writer.writerows(comments) 44 | -------------------------------------------------------------------------------- /yt_auth.py: -------------------------------------------------------------------------------- 1 | from googleapiclient.discovery import build 2 | from google_auth_oauthlib.flow import InstalledAppFlow 3 | import csv 4 | 5 | from tabulate import tabulate 6 | 7 | SCOPES = ['https://www.googleapis.com/auth/yt-analytics.readonly'] 8 | 9 | API_SERVICE_NAME = 'youtubeAnalytics' 10 | API_VERSION = 'v2' 11 | CLIENT_SECRETS_FILE = 'client_secret.json' 12 | 13 | def get_service(): 14 | flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRETS_FILE, SCOPES) 15 | credentials = flow.run_console() 16 | # alternatively (https://github.com/onlyphantom/youtube_api_python/pull/3/files): 17 | # credentials = flow.run_local_server() 18 | return build(API_SERVICE_NAME, API_VERSION, credentials = credentials) 19 | 20 | def execute_api_request(client_library_function, **kwargs): 21 | response = client_library_function( 22 | **kwargs 23 | ).execute() 24 | return response 25 | 26 | def create_table(table, headers=None): 27 | if headers: 28 | headerstring = "\t{}\t" * len(headers) 29 | print(headerstring.format(*headers)) 30 | 31 | rowstring = "\t{}\t" * len(table[0]) 32 | 33 | for row in table: 34 | print(rowstring.format(*row)) 35 | 36 | def create_csv(table, headers=None, filename='output.csv'): 37 | with open(filename, 'w') as f: 38 | writer = csv.writer(f) 39 | if headers: 40 | writer.writerow(headers) 41 | writer.writerows(table) 42 | 43 | if __name__ == '__main__': 44 | 45 | youtubeAnalytics = get_service() 46 | result = execute_api_request( 47 | youtubeAnalytics.reports().query, 48 | ids='channel==MINE', 49 | startDate='2022-05-01', 50 | endDate='2023-04-30', 51 | metrics='estimatedMinutesWatched,views,likes,subscribersGained', 52 | dimensions='day', 53 | sort='day' 54 | ) 55 | headers = ['date', 'estMinutesWatched', 'views', 'likes', 'subscribersGained'] 56 | # create_table(result['rows'], headers=headers) 57 | create_csv(result['rows'], headers=headers) 58 | print(tabulate(result['rows'], headers=headers, tablefmt="pretty")) 59 | 60 | -------------------------------------------------------------------------------- /yt_public.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from googleapiclient.discovery import build 4 | from iteration_utilities import unique_everseen 5 | 6 | from utils.comments import process_comments, make_csv 7 | 8 | load_dotenv() 9 | API_KEY = os.getenv("API_KEY") 10 | 11 | youtube = build("youtube", "v3", developerKey=API_KEY) 12 | 13 | def search_result(query): 14 | """ 15 | Refer to the documentation: https://googleapis.github.io/google-api-python-client/docs/dyn/youtube_v3.search.html 16 | """ 17 | request = youtube.search().list( 18 | part="snippet", 19 | q=query, 20 | maxResults=10, 21 | ) 22 | 23 | return request.execute() 24 | 25 | def channel_stats(channelID): 26 | """ 27 | Refer to the documentation: https://googleapis.github.io/google-api-python-client/docs/dyn/youtube_v3.channels.html 28 | """ 29 | request = youtube.channels().list( 30 | part="statistics", 31 | id=channelID 32 | ) 33 | return request.execute() 34 | 35 | def comment_threads(videoID, to_csv=False): 36 | 37 | comments_list = [] 38 | 39 | request = youtube.commentThreads().list( 40 | part='id,replies,snippet', 41 | videoId=videoID, 42 | ) 43 | response = request.execute() 44 | comments_list.extend(process_comments(response['items'])) 45 | 46 | # if there is nextPageToken, then keep calling the API 47 | while response.get('nextPageToken', None): 48 | request = youtube.commentThreads().list( 49 | part='id,replies,snippet', 50 | videoId=videoID, 51 | pageToken=response['nextPageToken'] 52 | ) 53 | response = request.execute() 54 | comments_list.extend(process_comments(response['items'])) 55 | 56 | comments_list = list(unique_everseen(comments_list)) 57 | 58 | print(f"Finished fetching comments for {videoID}. {len(comments_list)} comments found.") 59 | 60 | if to_csv: 61 | make_csv(comments_list, videoID) 62 | 63 | return comments_list 64 | 65 | 66 | def get_video_ids(channelId): 67 | """ 68 | Refer to the documentation: https://googleapis.github.io/google-api-python-client/docs/dyn/youtube_v3.search.html 69 | """ 70 | videoIds = [] 71 | 72 | request = youtube.search().list( 73 | part="snippet", 74 | channelId=channelId, 75 | type="video", 76 | maxResults=50, 77 | order="date" 78 | ) 79 | 80 | response = request.execute() 81 | responseItems = response['items'] 82 | 83 | videoIds.extend([item['id']['videoId'] for item in responseItems if item['id'].get('videoId', None) != None]) 84 | 85 | # if there is nextPageToken, then keep calling the API 86 | while response.get('nextPageToken', None): 87 | request = youtube.search().list( 88 | part="snippet", 89 | channelId=channelId, 90 | ) 91 | response = request.execute() 92 | responseItems = response['items'] 93 | 94 | videoIds.extend([item['id']['videoId'] for item in responseItems if item['id'].get('videoId', None) != None]) 95 | 96 | print(f"Finished fetching videoIds for {channelId}. {len(videoIds)} videos found.") 97 | 98 | return videoIds 99 | 100 | 101 | 102 | if __name__ == '__main__': 103 | # to get a list of IDs from a channel 104 | # ids = get_video_ids("UCws1b7urfxMnvjhUJxHjtuQ") 105 | 106 | # to get results from search 107 | # response = search_result("pyscript") 108 | 109 | # get channel stats 110 | # response = channel_stats(channelID='UCzIxc8Vg53_ewaRIk3shBug') 111 | 112 | # get comments 113 | response = comment_threads(videoID='Qo8dXyKXyME', to_csv=True) 114 | 115 | print(response) 116 | --------------------------------------------------------------------------------