Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit da05d6b

Browse files
Add channel video discovery with 24-hour filtering (#34)
* Initial plan * Add channel video discovery feature for last 24 hours Co-authored-by: greenbrettmichael <10648075+greenbrettmichael@users.noreply.github.com> * Add clarifying comments addressing code review feedback Co-authored-by: greenbrettmichael <10648075+greenbrettmichael@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: greenbrettmichael <10648075+greenbrettmichael@users.noreply.github.com>
1 parent 3eb76f4 commit da05d6b

5 files changed

Lines changed: 578 additions & 22 deletions

File tree

README.md

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,54 @@ Create an `email_list.json` file in the project root directory with the followin
9191
},
9292
{
9393
"email": "user2@example.com",
94-
"search_url": "https://www.youtube.com/results?search_query=ai+news&sp=EgIIAw%253D%253D"
94+
"channel_username": "LinusTechTips"
95+
},
96+
{
97+
"email": "user3@example.com",
98+
"channel_id": "UC8butISFwT-Wl7EV0hUK0BQ"
99+
},
100+
{
101+
"email": "user4@example.com",
102+
"channel_url": "https://www.youtube.com/@mkbhd"
103+
},
104+
{
105+
"email": "user5@example.com",
106+
"channel_username": "ThePrimeagen",
107+
"search_url": "https://www.youtube.com/results?search_query=programming&sp=EgIIAw%253D%253D"
95108
}
96109
]
97110
```
98111

99112
**Configuration File Format:**
100113
- The file must be a JSON array of objects
101-
- Each object represents a recipient/search URL pairing
114+
- Each object represents a recipient and their video sources
102115
- Required fields for each entry:
103116
- `email`: Recipient email address (must contain '@')
104-
- `search_url`: Full YouTube search URL (see below for how to construct)
117+
- At least one video source (can have multiple):
118+
- `search_url`: Full YouTube search URL for keyword-based searches
119+
- `channel_id`: YouTube channel ID (e.g., "UC8butISFwT-Wl7EV0hUK0BQ")
120+
- `channel_url`: YouTube channel URL (e.g., "https://www.youtube.com/@mkbhd")
121+
- `channel_username`: YouTube channel username without @ (e.g., "LinusTechTips")
122+
123+
**Using Channel Sources:**
124+
- When you specify a channel (via `channel_id`, `channel_url`, or `channel_username`), the tool will:
125+
- Query the channel for videos published in the last 24 hours
126+
- Process transcripts for all videos found
127+
- Include them in the newsletter digest
128+
- You can specify multiple sources per recipient (e.g., both a channel and a search URL)
129+
- Channel videos are fetched using `scrapetube.get_channel()` sorted by newest first
105130

106131
**How to Construct YouTube Search URLs:**
107132
1. Go to YouTube and perform your desired search
108133
2. Apply any filters (upload date, duration, etc.)
109134
3. Copy the complete URL from your browser's address bar
110135
4. The URL should include the `sp` parameter for filters, e.g., `sp=EgIIAw%253D%253D` for videos uploaded this week
111136

137+
**Finding Channel Identifiers:**
138+
- **Channel Username**: The handle shown on the channel page (without the @), e.g., "LinusTechTips"
139+
- **Channel URL**: The full URL to the channel page, e.g., "https://www.youtube.com/@mkbhd"
140+
- **Channel ID**: Found in the page source or channel URL, e.g., "UC8butISFwT-Wl7EV0hUK0BQ"
141+
112142
**Example:** An `email_list.json.example` file is provided in the repository for reference.
113143

114144
### Running the Main Script
@@ -121,10 +151,12 @@ python app.py
121151

122152
- The application processes each entry in the configuration file
123153
- For each entry, it will:
124-
1. Fetch transcripts for up to 2 videos matching the search URL
125-
2. Generate an AI newsletter digest
126-
3. Send the personalized newsletter to the recipient email
154+
1. Fetch videos from the last 24 hours from any specified channels
155+
2. Fetch transcripts for videos matching any search URLs
156+
3. Generate an AI newsletter digest
157+
4. Send the personalized newsletter to the recipient email
127158
- If any entry fails, the application logs the error and continues with the next entry
159+
- The tool logs the number of videos found and which channels were processed
128160

129161
### Core Functionality
130162

app.py

Lines changed: 197 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,14 @@ def load_email_list_config(config_path: str = "email_list.json") -> list[dict]:
5353
Returns:
5454
list[dict]: A list of validated configuration entries, each containing:
5555
- email (str): Recipient email address
56-
- search_url (str): YouTube search URL
56+
- search_url (str, optional): YouTube search URL
57+
- channel_id (str, optional): YouTube channel ID
58+
- channel_url (str, optional): YouTube channel URL
59+
- channel_username (str, optional): YouTube channel username
60+
61+
Note:
62+
Each entry must have either a search_url OR at least one channel field
63+
(channel_id, channel_url, or channel_username).
5764
5865
Raises:
5966
FileNotFoundError: If the configuration file doesn't exist.
@@ -78,20 +85,45 @@ def load_email_list_config(config_path: str = "email_list.json") -> list[dict]:
7885

7986
email = entry.get("email")
8087
search_url = entry.get("search_url")
88+
channel_id = entry.get("channel_id")
89+
channel_url = entry.get("channel_url")
90+
channel_username = entry.get("channel_username")
8191

8292
if not email or not isinstance(email, str) or not email.strip():
8393
logging.warning(f"Entry at index {idx} missing or invalid 'email' field")
8494
continue
8595

86-
if not search_url or not isinstance(search_url, str) or not search_url.strip():
87-
logging.warning(f"Entry at index {idx} missing or invalid 'search_url' field")
88-
continue
89-
9096
# Basic email format validation
9197
if "@" not in email:
9298
logging.warning(f"Entry at index {idx} has invalid email format")
9399
continue
94-
validated_entries.append({"email": email.strip(), "search_url": search_url.strip()})
100+
101+
# Validate that at least one source is provided (search_url OR channel)
102+
has_search_url = search_url and isinstance(search_url, str) and search_url.strip()
103+
has_channel_id = channel_id and isinstance(channel_id, str) and channel_id.strip()
104+
has_channel_url = channel_url and isinstance(channel_url, str) and channel_url.strip()
105+
has_channel_username = channel_username and isinstance(channel_username, str) and channel_username.strip()
106+
107+
if not (has_search_url or has_channel_id or has_channel_url or has_channel_username):
108+
logging.warning(
109+
f"Entry at index {idx} missing valid 'search_url' or channel field "
110+
"(channel_id, channel_url, or channel_username)"
111+
)
112+
continue
113+
114+
# Build validated entry
115+
validated_entry = {"email": email.strip()}
116+
# Additional checks help mypy understand these are not None
117+
if has_search_url and search_url:
118+
validated_entry["search_url"] = search_url.strip()
119+
if has_channel_id and channel_id:
120+
validated_entry["channel_id"] = channel_id.strip()
121+
if has_channel_url and channel_url:
122+
validated_entry["channel_url"] = channel_url.strip()
123+
if has_channel_username and channel_username:
124+
validated_entry["channel_username"] = channel_username.strip()
125+
126+
validated_entries.append(validated_entry)
95127

96128
if len(validated_entries) == 0:
97129
logging.warning("Configuration file contains no valid entries")
@@ -101,6 +133,141 @@ def load_email_list_config(config_path: str = "email_list.json") -> list[dict]:
101133
return validated_entries
102134

103135

136+
def is_video_within_last_day(video: dict) -> bool:
137+
"""
138+
Checks if a video was published within the last 24 hours.
139+
140+
Args:
141+
video (dict): A video dictionary from scrapetube containing publishedTimeText.
142+
143+
Returns:
144+
bool: True if the video was published within the last 24 hours, False otherwise.
145+
"""
146+
try:
147+
published_text = video.get("publishedTimeText", {}).get("simpleText", "")
148+
if not published_text:
149+
return False
150+
151+
# Parse relative time strings like "2 hours ago", "1 day ago", etc.
152+
published_text_lower = published_text.lower()
153+
154+
# Check for videos from within the last day
155+
if "minute" in published_text_lower or "hour" in published_text_lower:
156+
return True
157+
elif "day" in published_text_lower:
158+
# Extract the number of days
159+
parts = published_text_lower.split()
160+
try:
161+
num_days = int(parts[0])
162+
return num_days <= 1
163+
except (ValueError, IndexError):
164+
return False
165+
else:
166+
# Anything else (weeks, months, years) is not within the last day
167+
return False
168+
except Exception as e:
169+
logging.warning(f"Error parsing video publish date: {e}")
170+
return False
171+
172+
173+
def get_channel_videos_last_day(
174+
channel_id: str | None = None,
175+
channel_url: str | None = None,
176+
channel_username: str | None = None,
177+
api_client: YouTubeTranscriptApi | None = None,
178+
) -> list[dict]:
179+
"""
180+
Retrieves videos from a channel that were published within the last 24 hours.
181+
182+
Args:
183+
channel_id (str, optional): The YouTube channel ID.
184+
channel_url (str, optional): The YouTube channel URL.
185+
channel_username (str, optional): The YouTube channel username (without @).
186+
api_client (YouTubeTranscriptApi, optional): An instance of YouTubeTranscriptApi.
187+
188+
Returns:
189+
list[dict]: List of dictionaries containing video_id, title, and transcript.
190+
"""
191+
channel_identifier = channel_id or channel_url or channel_username
192+
logging.info(f"Fetching videos from channel: {channel_identifier}")
193+
194+
try:
195+
# Get videos from the channel, sorted by newest first
196+
# Note: Using scrapetube.get_channel() (top-level API) instead of
197+
# scrapetube.scrapetube.get_videos() (lower-level function) for channel queries
198+
channel_videos = scrapetube.get_channel(
199+
channel_id=channel_id,
200+
channel_url=channel_url,
201+
channel_username=channel_username,
202+
sort_by="newest",
203+
sleep=YOUTUBE_SEARCH_SLEEP_SECONDS,
204+
)
205+
206+
# Filter videos from the last 24 hours
207+
recent_videos = []
208+
for video in channel_videos:
209+
if is_video_within_last_day(video):
210+
recent_videos.append(video)
211+
else:
212+
# Since videos are sorted by newest, we can stop once we hit an older video
213+
break
214+
215+
logging.info(f"Found {len(recent_videos)} videos from the last 24 hours for channel: {channel_identifier}")
216+
217+
# Process transcripts for the recent videos
218+
results_data = []
219+
transcript_api = api_client or get_transcript_api()
220+
221+
for video in recent_videos:
222+
video_id = video.get("videoId")
223+
try:
224+
title = video["title"]["runs"][0]["text"]
225+
except (KeyError, IndexError):
226+
title = "Unknown Title"
227+
228+
logging.info(f"Processing channel video: {title} [{video_id}]")
229+
230+
try:
231+
transcript_list_obj = transcript_api.list(video_id)
232+
233+
# Try to find English variants first
234+
try:
235+
transcript_obj = transcript_list_obj.find_transcript(["en", "en-US", "en-GB"])
236+
logging.info(
237+
f"Found English transcript for video ID: {video_id} with language code: {transcript_obj.language_code}"
238+
)
239+
except:
240+
# Fallback: If no English, just take the first available one
241+
transcript_obj = next(iter(transcript_list_obj))
242+
logging.info(
243+
f"No English transcript found. Using available transcript with language code: {transcript_obj.language_code} for video ID: {video_id}"
244+
)
245+
246+
# fetch() returns a list of dictionaries with 'text', 'start', and 'duration'
247+
fetched_transcript = transcript_obj.fetch()
248+
249+
# Preserve transcript items with timestamps
250+
transcript_items = [{"text": item.text, "start": item.start} for item in fetched_transcript]
251+
252+
except TranscriptsDisabled:
253+
logging.info(f"Transcripts are disabled for video ID: {video_id}")
254+
continue
255+
except NoTranscriptFound:
256+
logging.info(f"No transcript found for video ID: {video_id}.")
257+
continue
258+
except Exception as e:
259+
logging.info(f"Error retrieving transcript for video ID: {video_id}: {str(e)}")
260+
continue
261+
262+
results_data.append({"video_id": video_id, "title": title, "transcript": transcript_items})
263+
264+
return results_data
265+
266+
except Exception as e:
267+
logging.error(f"Error fetching channel videos: {e}")
268+
return []
269+
270+
104271
def get_recent_transcripts(url: str, limit: int = 10, api_client: YouTubeTranscriptApi | None = None) -> list[dict]:
105272
"""
106273
Searches for the most recent videos by URL and retrieves their transcripts.
@@ -438,17 +605,37 @@ def send_newsletter_resend(subject: str, body: str, recipients: list):
438605
# Process each configuration entry
439606
for idx, entry in enumerate(config_entries):
440607
recipient_email = entry["email"]
441-
search_url = entry["search_url"]
608+
search_url = entry.get("search_url")
609+
channel_id = entry.get("channel_id")
610+
channel_url = entry.get("channel_url")
611+
channel_username = entry.get("channel_username")
442612

443613
logging.info(f"\n{'=' * 60}")
444614
logging.info(f"Processing entry {idx + 1}/{len(config_entries)}")
445615
logging.info(f"Recipient: {recipient_email}")
446-
logging.info(f"Search URL: {search_url}")
616+
if search_url:
617+
logging.info(f"Search URL: {search_url}")
618+
if channel_id:
619+
logging.info(f"Channel ID: {channel_id}")
620+
if channel_url:
621+
logging.info(f"Channel URL: {channel_url}")
622+
if channel_username:
623+
logging.info(f"Channel Username: {channel_username}")
447624
logging.info(f"{'=' * 60}\n")
448625

449626
try:
450-
# Fetch transcripts
451-
data = get_recent_transcripts(search_url, limit=2)
627+
# Fetch transcripts from channels (if specified)
628+
data = []
629+
if channel_id or channel_url or channel_username:
630+
channel_data = get_channel_videos_last_day(
631+
channel_id=channel_id, channel_url=channel_url, channel_username=channel_username
632+
)
633+
data.extend(channel_data)
634+
635+
# Also fetch from search URL if specified
636+
if search_url:
637+
search_data = get_recent_transcripts(search_url, limit=2)
638+
data.extend(search_data)
452639

453640
if not data:
454641
logging.warning(f"No transcripts found for {recipient_email}, skipping...")

email_list.json.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,22 @@
66
{
77
"email": "user2@example.com",
88
"search_url": "https://www.youtube.com/results?search_query=ai+news&sp=EgIIAw%253D%253D"
9+
},
10+
{
11+
"email": "user3@example.com",
12+
"channel_username": "LinusTechTips"
13+
},
14+
{
15+
"email": "user4@example.com",
16+
"channel_id": "UC8butISFwT-Wl7EV0hUK0BQ"
17+
},
18+
{
19+
"email": "user5@example.com",
20+
"channel_url": "https://www.youtube.com/@mkbhd"
21+
},
22+
{
23+
"email": "user6@example.com",
24+
"channel_username": "ThePrimeagen",
25+
"search_url": "https://www.youtube.com/results?search_query=programming&sp=EgIIAw%253D%253D"
926
}
1027
]

0 commit comments

Comments
 (0)