diff --git a/.gitignore b/.gitignore index ecdd11e..c0aac48 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ _site/ # Personal ingestor configuration — keep these local, never commit real values. # Copy the corresponding .example file and fill in your own IDs/usernames. config/youtube_playlists.txt +config/youtube_channels.txt config/hackernews.txt diff --git a/blog/generate.py b/blog/generate.py index e337d23..930812e 100644 --- a/blog/generate.py +++ b/blog/generate.py @@ -43,7 +43,7 @@ from jinja2 import Environment, FileSystemLoader # noqa: E402 import urllib.parse # noqa: E402 -from blog.ingestors import github_issues, hackernews, youtube # noqa: E402 +from blog.ingestors import github_issues, github_profile, hackernews, youtube # noqa: E402 # --------------------------------------------------------------------------- # Paths @@ -102,41 +102,84 @@ def generate_site( token: str | None, output_dir: Path, youtube_playlist_ids: str | None = None, + youtube_channel_ids: str | None = None, hn_usernames: list[str] | None = None, ) -> None: _start = time.monotonic() + repo_owner = repo.split("/")[0] repo_name = repo.split("/")[-1] repo_url = f"https://github.com/{repo}" + # Build GitHub API request headers + gh_headers: dict[str, str] = {"Accept": "application/vnd.github+json"} + if token: + gh_headers["Authorization"] = f"Bearer {token}" + # Load config files early so we can pass them to the config page hidden_labels = github_issues._load_hidden_labels(CONFIG_DIR) blocked_users = github_issues._load_blocked_users(CONFIG_DIR) + # --- GitHub owner profile & social links --- + print(f"Fetching GitHub profile for: {repo_owner}…") + owner_profile = github_profile.fetch_owner_profile(repo_owner, gh_headers) + if owner_profile: + print(f" Profile: {owner_profile.name or owner_profile.login}") + print(f" Social links: {len(owner_profile.social_links)} found.") + else: + print(" Could not fetch GitHub profile — social link auto-discovery disabled.") + # --- GitHub Issues (My Writing) — always runs --- print("Fetching GitHub Issues (My Writing)…") writing_posts = github_issues.ingest(repo, token, CONFIG_DIR) print(f" {len(writing_posts)} post(s) ingested from GitHub Issues.") - # --- YouTube playlists (My Watching) — uses free public RSS feeds, no API key --- + # --- YouTube playlists & channels (My Watching) — uses free public RSS feeds, no API key --- watching_posts: list[dict] = [] playlist_ids = youtube.load_playlist_ids(CONFIG_DIR, youtube_playlist_ids) - if playlist_ids: - print("Fetching YouTube playlists (My Watching)…") - watching_posts = youtube.ingest(CONFIG_DIR, youtube_playlist_ids) + channel_ids = youtube.load_channel_ids(CONFIG_DIR, youtube_channel_ids) + + # Auto-discover YouTube channel from GitHub social links when not explicitly configured + profile_youtube_handles: list[str] = [] + if owner_profile: + profile_youtube_handles = github_profile.extract_youtube_handles(owner_profile.social_links) + auto_discovered_channels = ( + profile_youtube_handles + if (not channel_ids and profile_youtube_handles) + else [] + ) + effective_channel_ids_str = youtube_channel_ids + if auto_discovered_channels and not channel_ids: + print(f" Auto-discovered YouTube channel(s) from GitHub profile: {auto_discovered_channels}") + effective_channel_ids_str = ",".join(auto_discovered_channels) + channel_ids = youtube.load_channel_ids(CONFIG_DIR, effective_channel_ids_str) + + if playlist_ids or channel_ids: + print("Fetching YouTube content (My Watching)…") + watching_posts = youtube.ingest(CONFIG_DIR, youtube_playlist_ids, effective_channel_ids_str) print(f" {len(watching_posts)} post(s) ingested from YouTube.") else: - print("YOUTUBE_PLAYLIST_IDS not configured — skipping YouTube ingestor.") + print("YOUTUBE_PLAYLIST_IDS / YOUTUBE_CHANNEL_IDS not configured and none found in GitHub profile — skipping YouTube ingestor.") - # --- Hacker News (My Reading) — requires HN_USERNAME --- + # --- Hacker News (My Reading) — HN_USERNAME env var, or auto-discovered from GitHub profile --- reading_posts: list[dict] = [] - if hn_usernames: - names_str = ", ".join(hn_usernames) + auto_discovered_hn_username: str | None = None + effective_hn_usernames = hn_usernames # start with whatever was explicitly configured + + if not effective_hn_usernames and owner_profile: + discovered = github_profile.extract_hn_username(owner_profile.social_links) + if discovered: + auto_discovered_hn_username = discovered + effective_hn_usernames = [discovered] + print(f" Auto-discovered HN username from GitHub profile: {discovered}") + + if effective_hn_usernames: + names_str = ", ".join(effective_hn_usernames) print(f"Fetching Hacker News (My Reading) for: {names_str}…") - reading_posts = hackernews.ingest(hn_usernames) + reading_posts = hackernews.ingest(effective_hn_usernames) print(f" {len(reading_posts)} post(s) ingested from Hacker News.") else: - print("HN_USERNAME not configured — skipping Hacker News ingestor.") + print("HN_USERNAME not configured and none found in GitHub profile — skipping Hacker News ingestor.") # Build active sections (skip sections that produced no posts) section_posts = { @@ -157,6 +200,44 @@ def generate_site( reverse=True, ) + # --- Sidebar data --- + # Split HN posts into stories vs. comments for separate sidebar panels + _SIDEBAR_LIMIT = 5 + hn_stories = [p for p in reading_posts if p.get("metadata", {}).get("hn_type") == "story"] + hn_comments = [p for p in reading_posts if p.get("metadata", {}).get("hn_type") == "comment"] + # Build per-username HN profile links (use first effective username if multiple) + _hn_user = (effective_hn_usernames or [None])[0] + hn_submitted_url = ( + f"https://news.ycombinator.com/submitted?id={_hn_user}" if _hn_user else None + ) + hn_threads_url = ( + f"https://news.ycombinator.com/threads?id={_hn_user}" if _hn_user else None + ) + hn_profile_url = ( + f"https://news.ycombinator.com/user?id={_hn_user}" if _hn_user else None + ) + + # Collect unique YouTube "view more" URLs (one per playlist/channel) + seen_view_more: set[str] = set() + youtube_view_more_urls: list[dict] = [] + for p in watching_posts: + vmu = p.get("metadata", {}).get("view_more_url") + stype = p.get("metadata", {}).get("source_type", "playlist") + if vmu and vmu not in seen_view_more: + seen_view_more.add(vmu) + youtube_view_more_urls.append({"url": vmu, "source_type": stype}) + + sidebar = { + "hn_stories": hn_stories[:_SIDEBAR_LIMIT], + "hn_comments": hn_comments[:_SIDEBAR_LIMIT], + "hn_submitted_url": hn_submitted_url, + "hn_threads_url": hn_threads_url, + "hn_profile_url": hn_profile_url, + "hn_username": _hn_user, + "watching": watching_posts[:_SIDEBAR_LIMIT], + "youtube_view_more_urls": youtube_view_more_urls, + } + # --- Jinja2 setup --- env = Environment( loader=FileSystemLoader(str(TEMPLATES_DIR)), @@ -204,7 +285,7 @@ def generate_site( # Render index page index_tmpl = env.get_template("index.html") - index_html = index_tmpl.render(sections=active_sections) + index_html = index_tmpl.render(sections=active_sections, sidebar=sidebar) (output_dir / "index.html").write_text(index_html, encoding="utf-8") print("Wrote index.html") @@ -233,8 +314,12 @@ def generate_site( # Render config page config_ctx = { - "hn_usernames": hn_usernames or [], + "hn_usernames": effective_hn_usernames or [], + "auto_discovered_hn_username": auto_discovered_hn_username, "playlist_ids": playlist_ids, + "channel_ids": channel_ids, + "auto_discovered_channels": auto_discovered_channels, + "owner_profile": owner_profile, "hidden_labels": sorted(hidden_labels), "blocked_user_count": len(blocked_users), "writing_post_count": len(writing_posts), @@ -270,6 +355,7 @@ def main() -> None: output_dir = Path(os.environ.get("OUTPUT_DIR", "_site")).resolve() youtube_playlist_ids = os.environ.get("YOUTUBE_PLAYLIST_IDS") or None + youtube_channel_ids = os.environ.get("YOUTUBE_CHANNEL_IDS") or None # HN usernames: from HN_USERNAME env var and/or local config file (gitignored) hn_usernames = hackernews.load_usernames(CONFIG_DIR, os.environ.get("HN_USERNAME") or None) @@ -279,6 +365,7 @@ def main() -> None: token=token, output_dir=output_dir, youtube_playlist_ids=youtube_playlist_ids, + youtube_channel_ids=youtube_channel_ids, hn_usernames=hn_usernames or None, ) diff --git a/blog/ingestors/github_profile.py b/blog/ingestors/github_profile.py new file mode 100644 index 0000000..c5923fd --- /dev/null +++ b/blog/ingestors/github_profile.py @@ -0,0 +1,208 @@ +""" +GitHub profile helper for SimpleGitBlog. + +Fetches the repository owner's public GitHub profile and linked social accounts +via the GitHub REST API. Social links are used to auto-discover optional +integrations (YouTube channel, Hacker News username, etc.) when explicit +configuration is absent. + +The social-accounts endpoint is public — no authentication required — though +a token raises the rate limit from 60 to 5 000 req/hour. + +Endpoints used: + GET /users/{username} → name, bio, avatar, website, twitter_username + GET /users/{username}/social_accounts → [{provider, url}, …] + +Recognised social providers and how they are used: + hackernews → https://news.ycombinator.com/user?id=username + Auto-discovers HN_USERNAME when env var not set. + youtube → https://www.youtube.com/@handle or /channel/UCxx + Auto-discovers YOUTUBE_CHANNEL_IDS when env var not set. + twitter → https://twitter.com/handle / https://x.com/handle + Displayed on config page (future integration). + linkedin → https://www.linkedin.com/in/slug + Displayed on config page (future integration). + +Social links are exposed on the config/transparency page so visitors can see +exactly where content is pulled from. +""" + +from __future__ import annotations + +import re +import urllib.parse +from typing import NamedTuple + +import requests + +_GITHUB_API = "https://api.github.com" +_TIMEOUT = 15 + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + +class SocialLink(NamedTuple): + provider: str # e.g. "youtube", "twitter", "linkedin" + url: str # canonical URL as stored on GitHub + + +class OwnerProfile(NamedTuple): + login: str + name: str | None + bio: str | None + avatar_url: str | None + website: str | None # the "blog" field on the profile + twitter_username: str | None # dedicated twitter_username field + social_links: list[SocialLink] + + +# --------------------------------------------------------------------------- +# Fetching +# --------------------------------------------------------------------------- + +def fetch_owner_profile(owner: str, headers: dict) -> OwnerProfile | None: + """ + Fetch the public GitHub profile and social accounts for ``owner``. + + Returns ``None`` if either request fails (network error, rate limit, etc.). + Failures are non-fatal — the blog simply skips auto-discovery. + """ + # --- Basic profile --- + try: + resp = requests.get( + f"{_GITHUB_API}/users/{owner}", + headers=headers, + timeout=_TIMEOUT, + ) + resp.raise_for_status() + profile_data = resp.json() + except requests.RequestException as exc: + print(f" Warning: could not fetch GitHub profile for {owner}: {exc}") + return None + + # --- Social accounts --- + social_links: list[SocialLink] = [] + try: + resp2 = requests.get( + f"{_GITHUB_API}/users/{owner}/social_accounts", + headers=headers, + timeout=_TIMEOUT, + ) + resp2.raise_for_status() + for item in resp2.json(): + provider = (item.get("provider") or "").lower().strip() + url = (item.get("url") or "").strip() + if provider and url: + social_links.append(SocialLink(provider=provider, url=url)) + except requests.RequestException as exc: + print(f" Warning: could not fetch social accounts for {owner}: {exc}") + # Non-fatal — continue with an empty list + + return OwnerProfile( + login=profile_data.get("login") or owner, + name=profile_data.get("name") or None, + bio=profile_data.get("bio") or None, + avatar_url=profile_data.get("avatar_url") or None, + website=profile_data.get("blog") or None, + twitter_username=profile_data.get("twitter_username") or None, + social_links=social_links, + ) + + +# --------------------------------------------------------------------------- +# Social link extraction helpers +# --------------------------------------------------------------------------- + +def extract_youtube_handles(social_links: list[SocialLink]) -> list[str]: + """ + Return YouTube channel handles or IDs found in the owner's social links. + + Recognised URL forms: + https://www.youtube.com/@handle + https://youtube.com/@handle + https://www.youtube.com/channel/UCxxxxxxxx + https://www.youtube.com/c/custom-name (treated as @custom-name) + https://www.youtube.com/user/username (treated as @username) + + Returns a list of strings that can be passed directly to + ``youtube.load_channel_ids()`` or ``youtube._resolve_channel_id()``. + """ + handles: list[str] = [] + for link in social_links: + if link.provider != "youtube": + continue + url = link.url.rstrip("/") + + # @handle form — handles only allow letters, digits, underscores, hyphens + m = re.search(r'youtube\.com/(@[A-Za-z0-9_-]+)', url, re.IGNORECASE) + if m: + handles.append(m.group(1)) + continue + + # /channel/UCxxxxxxxx (UC + exactly 22 base64-ish chars) + m = re.search(r'youtube\.com/channel/(UC[A-Za-z0-9_-]{22})', url, re.IGNORECASE) + if m: + handles.append(m.group(1)) + continue + + # /c/slug or /user/slug — treat as @slug (allow dots for legacy /user/ names) + m = re.search(r'youtube\.com/(?:c|user)/([A-Za-z0-9_-]+)', url, re.IGNORECASE) + if m: + handles.append(f"@{m.group(1)}") + continue + + return handles + + +def extract_twitter_url(social_links: list[SocialLink]) -> str | None: + """Return the first Twitter / X social link URL, or None.""" + for link in social_links: + if link.provider in ("twitter", "x"): + return link.url + return None + + +def extract_linkedin_url(social_links: list[SocialLink]) -> str | None: + """Return the first LinkedIn social link URL, or None.""" + for link in social_links: + if link.provider == "linkedin": + return link.url + return None + + +def extract_hn_username(social_links: list[SocialLink]) -> str | None: + """ + Return the Hacker News username found in the owner's social links, or None. + + GitHub stores HN accounts with provider ``"hackernews"`` (sometimes + ``"hacker-news"`` in older entries) and URLs of the form: + https://news.ycombinator.com/user?id=username + + The username is extracted from the ``id`` query parameter. + """ + _HN_PROVIDERS = {"hackernews", "hacker-news", "hn"} + + for link in social_links: + if link.provider.lower() in _HN_PROVIDERS: + # Extract username from ?id= query param + parsed = urllib.parse.urlparse(link.url) + params = urllib.parse.parse_qs(parsed.query) + ids = params.get("id", []) + if ids and ids[0].strip(): + return ids[0].strip() + # Fallback: bare username in path (e.g. news.ycombinator.com/~username) + path_m = re.search(r'/~?([A-Za-z0-9_-]+)$', parsed.path) + if path_m: + return path_m.group(1) + + # Provider-agnostic fallback: match any HN user URL regardless of provider label + if re.match(r'https?://news\.ycombinator\.com/user', link.url, re.IGNORECASE): + parsed = urllib.parse.urlparse(link.url) + params = urllib.parse.parse_qs(parsed.query) + ids = params.get("id", []) + if ids and ids[0].strip(): + return ids[0].strip() + + return None diff --git a/blog/ingestors/hackernews.py b/blog/ingestors/hackernews.py index edd2e6b..1823718 100644 --- a/blog/ingestors/hackernews.py +++ b/blog/ingestors/hackernews.py @@ -25,7 +25,7 @@ _HN_ALGOLIA_BASE = "https://hn.algolia.com/api/v1" _HN_ITEM_BASE = "https://news.ycombinator.com/item" _CONFIG_FILE = "hackernews.txt" -_MAX_HITS_PER_TYPE = 100 +_MAX_HITS_PER_TYPE = 20 # --------------------------------------------------------------------------- diff --git a/blog/ingestors/youtube.py b/blog/ingestors/youtube.py index 56875d2..2dd2227 100644 --- a/blog/ingestors/youtube.py +++ b/blog/ingestors/youtube.py @@ -1,26 +1,30 @@ """ -YouTube playlist ingestor for SimpleGitBlog. +YouTube ingestor for SimpleGitBlog. -Fetches videos from one or more YouTube playlists via YouTube's public Atom/RSS -feed — **no API key required**. +Fetches videos from one or more YouTube playlists AND/OR YouTube channels via +YouTube's public Atom/RSS feeds — **no API key required**. -Feed URL: - https://www.youtube.com/feeds/videos.xml?playlist_id={PLAYLIST_ID} +Feed URLs: + Playlist: https://www.youtube.com/feeds/videos.xml?playlist_id={PLAYLIST_ID} + Channel: https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID} -Each playlist returns up to the 15 most-recently-added videos. For a personal -"My Watching" section this is normally plenty; add multiple playlists (e.g. one -per year) if you need more history. +Each feed returns up to the 15 most-recently-added videos. Section: "watching" (My Watching) Configuration (GitHub Actions repository settings — do NOT hardcode values): Variable: YOUTUBE_PLAYLIST_IDS Comma-separated playlist IDs - -For local development, you may also place playlist IDs in -``config/youtube_playlists.txt`` (one per line, # comments supported). -That file is gitignored so your personal IDs stay off of version control. + Variable: YOUTUBE_CHANNEL_IDS Comma-separated channel IDs (UCxxxxxx) + or channel handles (@username) — handles + are resolved automatically. + +For local development, you may also place IDs in: + ``config/youtube_playlists.txt`` (one playlist ID per line) + ``config/youtube_channels.txt`` (one channel ID or @handle per line) +Both files are gitignored so your personal IDs stay off of version control. """ +import re import xml.etree.ElementTree as ET from pathlib import Path @@ -28,7 +32,8 @@ from blog.utils import extract_excerpt, format_date, format_datetime, plain_text_to_html -_CONFIG_FILE = "youtube_playlists.txt" +_PLAYLIST_CONFIG_FILE = "youtube_playlists.txt" +_CHANNEL_CONFIG_FILE = "youtube_channels.txt" _RSS_BASE = "https://www.youtube.com/feeds/videos.xml" # XML namespace map for YouTube Atom feeds @@ -60,7 +65,34 @@ def load_playlist_ids(config_dir: Path, env_playlist_ids: str | None = None) -> if pid: ids.add(pid) - config_file = config_dir / _CONFIG_FILE + config_file = config_dir / _PLAYLIST_CONFIG_FILE + if config_file.exists(): + for line in config_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + ids.add(line) + + return sorted(ids) + + +def load_channel_ids(config_dir: Path, env_channel_ids: str | None = None) -> list[str]: + """ + Return a deduplicated list of YouTube channel IDs (or @handles) from two sources: + + 1. ``env_channel_ids`` — the value of the YOUTUBE_CHANNEL_IDS env var + (comma-separated). Accepts ``UCxxxxxx`` channel IDs or ``@handle`` forms. + 2. ``config/youtube_channels.txt`` — optional local-dev override file + (gitignored; never committed with real IDs). + """ + ids: set[str] = set() + + if env_channel_ids: + for cid in env_channel_ids.split(","): + cid = cid.strip() + if cid: + ids.add(cid) + + config_file = config_dir / _CHANNEL_CONFIG_FILE if config_file.exists(): for line in config_file.read_text(encoding="utf-8").splitlines(): line = line.strip() @@ -70,32 +102,75 @@ def load_playlist_ids(config_dir: Path, env_playlist_ids: str | None = None) -> return sorted(ids) +def _resolve_channel_id(handle_or_id: str) -> str | None: + """ + Resolve a channel handle (``@username``) or channel ID (``UCxxxxxx``) to a + confirmed channel ID. + + If the argument looks like a channel ID already (starts with ``UC``), it is + returned as-is. Otherwise the channel page is fetched and the RSS feed link + (which contains the channel ID) is extracted from the HTML. + + Returns the channel ID string, or ``None`` if resolution fails. + """ + # Already looks like a channel ID + if re.match(r'^UC[A-Za-z0-9_-]{22}$', handle_or_id): + return handle_or_id + + # Build the canonical channel URL + if handle_or_id.startswith("@"): + channel_url = f"https://www.youtube.com/{handle_or_id}" + else: + channel_url = f"https://www.youtube.com/@{handle_or_id}" + + try: + resp = requests.get( + channel_url, + headers={"User-Agent": "Mozilla/5.0 (compatible; SimpleGitBlog/1.0)"}, + timeout=15, + ) + resp.raise_for_status() + except requests.RequestException as exc: + print(f" Warning: could not fetch channel page for {handle_or_id}: {exc}") + return None + + # Look for the RSS feed link in the page HTML, which contains the channel_id + m = re.search( + r'https://www\.youtube\.com/feeds/videos\.xml\?channel_id=(UC[A-Za-z0-9_-]+)', + resp.text, + ) + if m: + return m.group(1) + + print(f" Warning: could not extract channel ID from {channel_url}") + return None + + # --------------------------------------------------------------------------- # RSS / Atom feed helpers # --------------------------------------------------------------------------- -def _fetch_playlist_feed(playlist_id: str) -> list[dict]: +def _fetch_feed(url: str, label: str) -> list[dict]: """ - Fetch videos from a YouTube playlist Atom feed. + Fetch videos from a YouTube Atom feed (playlist or channel). Returns a list of raw entry dicts extracted from the feed. No API key required — the feed is publicly accessible. """ - url = f"{_RSS_BASE}?playlist_id={playlist_id}" try: response = requests.get(url, timeout=30) response.raise_for_status() except requests.HTTPError as exc: - print(f" Warning: YouTube RSS error for playlist {playlist_id}: {exc}") + print(f" Warning: YouTube RSS error for {label}: {exc}") return [] except requests.RequestException as exc: - print(f" Warning: YouTube RSS request failed for playlist {playlist_id}: {exc}") + print(f" Warning: YouTube RSS request failed for {label}: {exc}") return [] try: root = ET.fromstring(response.content) except ET.ParseError as exc: - print(f" Warning: could not parse YouTube RSS for playlist {playlist_id}: {exc}") + print(f" Warning: could not parse YouTube RSS for {label}: {exc}") return [] entries = [] @@ -136,11 +211,23 @@ def _fetch_playlist_feed(playlist_id: str) -> list[dict]: return entries +def _fetch_playlist_feed(playlist_id: str) -> list[dict]: + """Fetch videos from a YouTube playlist Atom feed.""" + url = f"{_RSS_BASE}?playlist_id={playlist_id}" + return _fetch_feed(url, f"playlist {playlist_id}") + + +def _fetch_channel_feed(channel_id: str) -> list[dict]: + """Fetch latest videos from a YouTube channel Atom feed.""" + url = f"{_RSS_BASE}?channel_id={channel_id}" + return _fetch_feed(url, f"channel {channel_id}") + + # --------------------------------------------------------------------------- # Post processing # --------------------------------------------------------------------------- -def _process_entry(entry: dict, playlist_id: str) -> dict | None: +def _process_entry(entry: dict, source_type: str, source_id: str, view_more_url: str) -> dict | None: """Convert a raw feed entry dict into the common post schema.""" video_id = entry.get("video_id", "").strip() if not video_id: @@ -177,9 +264,13 @@ def _process_entry(entry: dict, playlist_id: str) -> dict | None: "comments": [], "metadata": { "video_id": video_id, - "playlist_id": playlist_id, + "source_type": source_type, # "playlist" or "channel" + "source_id": source_id, + "view_more_url": view_more_url, "channel_name": author_name, "thumbnail_url": thumbnail_url, + # Legacy aliases kept for template compatibility + "playlist_id": source_id if source_type == "playlist" else None, }, } @@ -191,17 +282,23 @@ def _process_entry(entry: dict, playlist_id: str) -> dict | None: def ingest( config_dir: Path, env_playlist_ids: str | None = None, + env_channel_ids: str | None = None, ) -> list[dict]: """ - Fetch YouTube playlist videos via public RSS feeds and return posts in - the common schema. No API key required. + Fetch YouTube playlist and channel videos via public RSS feeds and return + posts in the common schema. No API key required. Playlist IDs come from ``env_playlist_ids`` (YOUTUBE_PLAYLIST_IDS env var) and/or the local ``config/youtube_playlists.txt`` file. + + Channel IDs/handles come from ``env_channel_ids`` (YOUTUBE_CHANNEL_IDS env + var) and/or the local ``config/youtube_channels.txt`` file. """ playlist_ids = load_playlist_ids(config_dir, env_playlist_ids) - if not playlist_ids: - print(" No YouTube playlist IDs configured.") + channel_ids_raw = load_channel_ids(config_dir, env_channel_ids) + + if not playlist_ids and not channel_ids_raw: + print(" No YouTube playlist IDs or channel IDs configured.") return [] posts: list[dict] = [] @@ -211,8 +308,32 @@ def ingest( print(f" Fetching playlist RSS: {playlist_id}") entries = _fetch_playlist_feed(playlist_id) print(f" {len(entries)} video(s) found.") + view_more_url = f"https://www.youtube.com/playlist?list={playlist_id}" + for entry in entries: + post = _process_entry(entry, "playlist", playlist_id, view_more_url) + if post is None: + continue + vid = post["metadata"]["video_id"] + if vid not in seen_video_ids: + seen_video_ids.add(vid) + posts.append(post) + + for raw_id in channel_ids_raw: + print(f" Resolving YouTube channel: {raw_id}") + channel_id = _resolve_channel_id(raw_id) + if not channel_id: + print(f" Skipping — could not resolve channel ID for: {raw_id}") + continue + print(f" Fetching channel RSS: {channel_id}") + entries = _fetch_channel_feed(channel_id) + print(f" {len(entries)} video(s) found.") + # Build a human-friendly "view more" URL using the original handle if given + if raw_id.startswith("@"): + view_more_url = f"https://www.youtube.com/{raw_id}/videos" + else: + view_more_url = f"https://www.youtube.com/channel/{channel_id}/videos" for entry in entries: - post = _process_entry(entry, playlist_id) + post = _process_entry(entry, "channel", channel_id, view_more_url) if post is None: continue vid = post["metadata"]["video_id"] diff --git a/blog/static/style.css b/blog/static/style.css index ac38a6e..e959a78 100644 --- a/blog/static/style.css +++ b/blog/static/style.css @@ -30,7 +30,7 @@ img { max-width: 100%; height: auto; } /* --- Layout ----------------------------------------------- */ .container { - max-width: 800px; + max-width: 1100px; margin: 0 auto; padding: 0 1.25rem; } @@ -509,6 +509,122 @@ main.container { padding-top: 2.5rem; padding-bottom: 2.5rem; } color: #334155; } +/* --- Two-column page layout ------------------------------- */ +.page-layout { + display: grid; + grid-template-columns: 1fr 320px; + gap: 2.5rem; + align-items: start; +} + +.main-column { min-width: 0; } + +/* --- Page sidebar ----------------------------------------- */ +.page-sidebar { + position: sticky; + top: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* --- Sidebar sections ------------------------------------- */ +.sidebar-section { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 1rem 1.1rem; +} + +.sidebar-section__heading { + font-size: 0.95rem; + font-weight: 700; + color: #111; + margin: 0 0 0.75rem; + display: flex; + align-items: center; + gap: 0.35rem; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 0.5rem; +} + +.sidebar-list { + list-style: none; + margin: 0 0 0.5rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sidebar-item { font-size: 0.85rem; } + +.sidebar-item__title { + display: block; + font-weight: 600; + color: #1e293b; + text-decoration: none; + line-height: 1.35; +} +.sidebar-item__title:hover { color: #0969da; text-decoration: underline; } + +.sidebar-item__meta { + font-size: 0.78rem; + color: #94a3b8; + margin-top: 0.2rem; +} +.sidebar-item__meta a { color: #94a3b8; } +.sidebar-item__meta a:hover { color: #0969da; } + +.sidebar-item__excerpt { + margin: 0.25rem 0 0; + font-size: 0.78rem; + color: #64748b; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* --- Video sidebar items ---------------------------------- */ +.sidebar-list--videos { gap: 0.8rem; } + +.sidebar-item--video { + display: flex; + gap: 0.65rem; + align-items: flex-start; +} + +.sidebar-item__thumb-link { flex-shrink: 0; } + +.sidebar-item__thumb { + width: 100px; + height: 56px; + object-fit: cover; + border-radius: 5px; + display: block; +} + +.sidebar-item__video-info { flex: 1; min-width: 0; } +.sidebar-item--video .sidebar-item__title { font-size: 0.82rem; } + +/* --- Sidebar "view all" link ------------------------------ */ +.sidebar-section__view-all { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: #0969da; + text-decoration: none; + margin-top: 0.5rem; +} +.sidebar-section__view-all:hover { text-decoration: underline; } + +.sidebar-section__view-more-links { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + /* --- Labels sidebar --------------------------------------- */ .labels-sidebar { margin-top: 3rem; @@ -561,6 +677,26 @@ main.container { padding-top: 2.5rem; padding-bottom: 2.5rem; } } .post-nav a:hover { color: #0969da; text-decoration: underline; } +.config-section__subheading { + font-size: 1rem; + font-weight: 700; + color: #334155; + margin: 1.25rem 0 0.4rem; +} + +.config-profile { + display: flex; + align-items: flex-start; + gap: 0.85rem; + margin-bottom: 1rem; +} + +.config-profile__avatar { + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; +} + /* --- Config page ------------------------------------------ */ .config-alert { padding: 0.9rem 1.25rem; @@ -658,6 +794,18 @@ a.config-detail:hover { color: #0969da; } } /* --- Responsive ------------------------------------------- */ +@media (max-width: 900px) { + .page-layout { + grid-template-columns: 1fr; + } + + .page-sidebar { + position: static; + border-top: 2px solid #e2e8f0; + padding-top: 2rem; + } +} + @media (max-width: 600px) { html { font-size: 16px; } diff --git a/blog/templates/config.html b/blog/templates/config.html index 580a68d..23e6f41 100644 --- a/blog/templates/config.html +++ b/blog/templates/config.html @@ -20,6 +20,79 @@
{{ owner_profile.bio }}
+ {% endif %} +| Platform | URL | Used for |
|---|---|---|
| {{ link.provider | capitalize }} | ++ + {{ link.url }} + + | ++ {% if link.provider == 'youtube' %} + {% if auto_discovered_channels %} + ✅ Auto-discovered — My Watching + {% else %} + Overridden by YOUTUBE_CHANNEL_IDS + {% endif %} + {% elif link.provider in ('hackernews', 'hacker-news', 'hn') %} + {% if auto_discovered_hn_username %} + ✅ Auto-discovered — My Reading + {% else %} + Overridden by HN_USERNAME + {% endif %} + {% elif link.provider in ('twitter', 'x') %} + — Displayed (future integration) + {% elif link.provider == 'linkedin' %} + — Displayed (future integration) + {% else %} + — Not yet integrated + {% endif %} + | +
Website: {{ owner_profile.website }}
+ {% endif %} + {% else %} +No social accounts linked on this GitHub profile.
+ {% endif %} +