Build a Local Company Signals Dashboard
Build a dashboard that normalizes company updates from YouTube and fixtures.
Introduction
30 Second Summary
Useful company updates rarely arrive in one tidy place. Each platform labels similar information differently, which makes a quick comparison harder than it should be.
In this project, you will build a local browser dashboard that turns several update formats into one consistent feed. Streamlit displays synthetic training data before the YouTube Data API v3 supplies current public uploads.
What You'll Build
You will open a filterable feed where updates from synthetic X fixtures, synthetic Instagram fixtures, plus live YouTube uploads share the same five clear columns.
By the end of this project, you'll have:
- A credential-free demo feed that lets you launch the dashboard immediately. You can compare synthetic X updates with synthetic Instagram updates.
- A normalized update table where every row uses the same company, source, title, publication time, plus engagement fields.
- A live YouTube view that loads recent public uploads with a masked restricted API key. A source filter plus a five-minute request cache let you change the table without waiting for another network call.
- Secret Mission: Add freshness labels that classify each update as Fresh, This week, Older, or Unknown.
Are there any prerequisites?
This project uses a Windows workspace with a credential-free demo mode. A Google Account with Google Cloud Console access is required for live YouTube updates.
Before We Start
Before the hands-on work begins, choose the companies you want your dashboard to monitor. Your reason for tracking them gives each update a clear purpose.
Set Up the Windows Workspace
Your dashboard depends on pinned Streamlit and Requests packages. A dedicated Python virtual environment keeps those packages isolated from every other project on your computer.
This step prepares a clean Windows workspace for the dashboard. You will finish by opening Streamlit's example app in your browser.
In this step, get ready to:
- Confirm Python 3.14.8 is available.
- Create the workspace with its pinned dependency file.
- Install the dependencies inside an isolated virtual environment.
Prepare Python and Notepad
Windows PowerShell lets you check which Python installation responds to commands. The dashboard needs Python 3.14.8 so its pinned packages behave consistently.
- Press the Windows key to open the Start menu.
- Type PowerShell into the search field.
- Select Windows PowerShell from the results.
- Check your Python version by running this command:
python --version
What does this command do?
The command asks the active Python installation to print its version. That result determines which setup path you need.
✔️ I see Python 3.14.8
Good start. Python 3.14.8 is ready for the dashboard workspace.
ⓧ I see an older version
Your current Python installation is older than the project requires. Install Python 3.14.8 before creating the virtual environment.
- Download the installer from the official Python Windows releases page.
- Run the downloaded Windows installer.
- Install Python 3.14.8 by following the installer prompts.
- Close the current PowerShell window after installation finishes.
- Press the Windows key to reopen the Start menu.
- Type PowerShell into the search field.
- Select Windows PowerShell from the results.
- Confirm the new Python version by running:
python --version
What should I see?
PowerShell should report Python 3.14.8. The fresh PowerShell session can detect the installation you just completed.
ⓧ Command not found
Windows cannot find a Python installation yet. Install Python 3.14.8 before continuing.
- Download the installer from the official Python Windows releases page.
- Run the downloaded Windows installer.
- Install Python 3.14.8 by following the installer prompts.
- Close the current PowerShell window after installation finishes.
- Press the Windows key to reopen the Start menu.
- Type PowerShell into the search field.
- Select Windows PowerShell from the results.
- Confirm Python is available by running:
python --version
What should I see?
PowerShell should report Python 3.14.8. This confirms Windows can now find the installed version.
Still seeing the wrong Python version?
Close every open PowerShell window after installing Python. A fresh window reloads the commands available to Windows.
If the result still shows an older version or no version, use this guide to help me make Python 3.14.8 respond to the python command.
Notepad gives you a simple editor for the dependency file. You will reuse it for the project files in later steps.
- Press the Windows key to open the Start menu.
- Type Notepad into the search field.
- Select Notepad from the results.
You should see an empty Notepad document ready for the dependency list.
Create the project folder and dependency file
File Explorer gives the dashboard a predictable home on your Desktop. The dependency file records the exact package versions that belong inside this workspace.
- Open File Explorer by pressing Windows+E.
- Select Desktop in the navigation pane.
- Create a new folder by pressing Ctrl+Shift+N.
- Type company-signals-dashboard into the highlighted name field.
You should see the new folder name on your Desktop.
- Press Enter to confirm the folder name.
- Double-click the company-signals-dashboard folder to enter it.
The File Explorer address bar should now end with company-signals-dashboard.
- Switch back to the Notepad window from earlier.
- Add the pinned dependencies by copying this content into the empty document:
streamlit==1.65.0
requests==2.34.2
What does this file control?
- The streamlit==1.65.0 line pins the framework that renders your local dashboard in a browser.
- The requests==2.34.2 line pins the library that will send live API requests later.
- Exact pins make every new installation use the same package behavior.
- Select File in Notepad.
- Select Save As.
- Select Desktop in the save window.
- Open the company-signals-dashboard folder.
- Enter requirements.txt in the File name field.
- Select Save.
Switch back to File Explorer. You should see requirements.txt inside company-signals-dashboard.
✔️ Awesome, I've got everything!
Great. Double-check that Notepad saved the file inside company-signals-dashboard.
ⓧ I'd like to double check the full code
Compare your complete requirements.txt file with this reference.
streamlit==1.65.0
requests==2.34.2
What should match?
Your file should contain these two lines in this order. Each line should use two equals signs before its version number.
- Click the File Explorer address bar inside company-signals-dashboard.
- Type powershell into the address bar.
- Press Enter to open PowerShell in this folder.
You should see a PowerShell prompt whose path ends with company-signals-dashboard.
Build and verify the virtual environment
The virtual environment stores this project's Python packages inside a local .venv folder. Activating it directs package installation into that isolated location.
- Create the virtual environment by running this command:
python -m venv .venv
What does this command do?
Python creates an isolated environment inside .venv. That folder holds the interpreter support files and packages for this dashboard.
PowerShell should return to the prompt after creating the environment. You can also see a new .venv folder after refreshing File Explorer.
- Activate the virtual environment by running this command:
.venv\Scripts\Activate.ps1
What does activation change?
Activation points Python and pip at the copies associated with .venv. The packages you install next stay tied to this workspace.
Your PowerShell prompt should now begin with (.venv). This prefix confirms the isolated environment is active.
✔️ I see (.venv)
Your virtual environment is active. The dependency installation can now stay isolated inside the project folder.
ⓧ PowerShell blocks the script
PowerShell can block local activation scripts through its execution policy. The documented current-user setting allows locally created scripts to run.
- Apply the current-user execution policy by running this command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
What does this setting change?
This setting applies to your Windows user account. It allows local scripts such as the virtual environment activation script to run.
- Close the current PowerShell window.
- Return to the company-signals-dashboard folder in File Explorer.
- Click the File Explorer address bar.
- Type powershell into the address bar.
- Press Enter to open a fresh PowerShell window.
- Activate the virtual environment again by running:
.venv\Scripts\Activate.ps1
What should change now?
The fresh PowerShell session uses the updated execution policy. Its prompt should begin with (.venv) after activation succeeds.
Activation still blocked?
Confirm that PowerShell is open inside company-signals-dashboard. The activation path only works from the folder that contains .venv.
Use this guide to help me activate the .venv environment in Windows PowerShell.
The first dependency installation can take a minute while pip downloads Streamlit and its supporting packages. PowerShell prints progress throughout the installation.
- Install the pinned dependencies from requirements.txt by running:
pip install -r requirements.txt
What does this command install?
The command reads both pinned entries from requirements.txt. It installs Streamlit 1.65.0 and Requests 2.34.2 inside the active environment.
PowerShell should return to the (.venv) prompt without reporting a failed installation.
Dependency installation failed?
Check that the prompt starts with (.venv). Reopen PowerShell from the project folder if the environment is inactive.
Confirm that requirements.txt contains the two lines from the double-check tab. A changed package name or version prevents the intended installation.
Use this guide to help me troubleshoot the pinned dependency installation.
The final command starts Streamlit's example server. It also opens the example app in your default browser.
Before you run it, what do you expect a successful Streamlit installation to open?
- Verify Streamlit by running this command:
python -m streamlit hello
What does this command prove?
Python starts Streamlit's built-in example application through the active environment. A working browser page confirms that the framework installed correctly.
Your browser should open the Streamlit example app. The PowerShell window stays occupied while the example server runs.
Browser did not open?
Check the PowerShell window for a local address. Open that address in your browser if the page did not launch automatically.
Confirm that the prompt showed (.venv) before you started Streamlit. An inactive environment can point Python at a different package location.
Use this guide to help me open the Streamlit Hello app from my virtual environment.
That's your workspace verified. Python can now run Streamlit from the isolated environment that holds your pinned dependencies.
- Return to the PowerShell window after capturing your screenshot.
- Stop the example server by pressing Ctrl+C.
Your Windows workspace is ready. Next, you will turn synthetic company updates into your first visible dashboard table.
Display the Raw Source Payloads
The workspace is ready. Streamlit is installed inside your virtual environment.
Company update sources can label similar information with different field names. This step loads those raw shapes unchanged so you can test whether one table can represent them clearly.
In this step, get ready to:
- Create a synthetic JSON fixture containing three company updates.
- Build a Streamlit app that renders the fixture unchanged.
- Run the dashboard to inspect its source-specific columns.
Create the synthetic fixture
The fixture gives your app repeatable input without requiring credentials. Its three records use project-authored X and Instagram shapes.
- Switch back to the activated PowerShell window from earlier.
- Create the data folder by running this command:
New-Item -Path data -ItemType Directory
What does this command do?
PowerShell creates a folder named data inside company-signals-dashboard. This folder keeps the fixture separate from the application code.
PowerShell prints a directory entry named data. This confirms the folder exists in your project.
Folder not created?
- Check that the PowerShell prompt points to the company-signals-dashboard folder.
- Confirm that (.venv) still appears at the start of the prompt.
Ask for help with creating the data folder in the correct project location.
- Press the Windows key to open the Windows search bar.
- Type Notepad and press Enter to open it.
- Paste the following fixture into the blank document:
[
{
"source": "X",
"account_name": "Northstar Labs",
"message": "Synthetic update: We published a preview of our new research workspace.",
"posted_at": "2026-10-08T09:00:00Z",
"engagement": 482
},
{
"source": "Instagram",
"profile_name": "Harbor Robotics",
"caption": "Synthetic update: Our warehouse robot completed its first night shift test.",
"timestamp": "2026-10-07T15:30:00Z",
"engagement_count": 311
},
{
"source": "X",
"account_name": "Cedar Analytics",
"message": "Synthetic update: A new public data report is available for review.",
"posted_at": "2026-10-06T12:15:00Z",
"engagement": 206
}
]
What is in this fixture?
- Each object represents one company update from a project-defined source shape.
- The account_name, message, posted_at, and engagement fields belong to the X-style records.
- The profile_name, caption, timestamp, and engagement_count fields belong to the Instagram-style record.
- The content is project-authored synthetic training data.
- Save the document as demo_updates.json inside the data folder in company-signals-dashboard.
Notepad's active tab should show demo_updates.json. Your synthetic fixture is now ready for the app.
Seeing a .txt suffix?
- Use Notepad's save command again.
- Enter demo_updates.json as the complete file name.
- Confirm that the file sits inside the data folder.
Ask for help with saving a JSON file from Notepad without a text suffix.
✔️ Awesome, I've got everything!
The fixture is saved inside the data folder.
ⓧ I'd like to double check the full code
The complete fixture should match this reference.
[
{
"source": "X",
"account_name": "Northstar Labs",
"message": "Synthetic update: We published a preview of our new research workspace.",
"posted_at": "2026-10-08T09:00:00Z",
"engagement": 482
},
{
"source": "Instagram",
"profile_name": "Harbor Robotics",
"caption": "Synthetic update: Our warehouse robot completed its first night shift test.",
"timestamp": "2026-10-07T15:30:00Z",
"engagement_count": 311
},
{
"source": "X",
"account_name": "Cedar Analytics",
"message": "Synthetic update: A new public data report is available for review.",
"posted_at": "2026-10-06T12:15:00Z",
"engagement": 206
}
]
Build the raw dashboard
The first app reads the fixture into a Python list. It sends that list directly to an interactive table without changing any fields.
- Create a new blank document in Notepad.
- Paste the following code into the document:
import json
from pathlib import Path
import streamlit as st
DEMO_DATA_PATH = Path("data/demo_updates.json")
payloads = json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8"))
st.write("# Company Signals Dashboard")
st.write("Raw synthetic source payloads")
st.dataframe(payloads)
What does this code do?
- The imports provide JSON parsing, file paths, and the Streamlit interface.
- DEMO_DATA_PATH points to the fixture inside the data folder.
- payloads holds the unmodified list read from the fixture.
- st.write() adds the dashboard heading and raw-data subtitle.
- st.dataframe() renders the source records as an interactive table.
- Save the document as app.py inside the company-signals-dashboard folder.
Notepad's active tab should show app.py. The application file now sits beside requirements.txt.
App saved as a text file?
- Use Notepad's save command again.
- Enter app.py as the complete file name.
- Save the file directly inside company-signals-dashboard.
Ask for help with saving app.py in the correct folder from Notepad.
✔️ Awesome, I've got everything!
Your application file is saved beside requirements.txt.
ⓧ I'd like to double check the full code
The complete application file should match this reference.
import json
from pathlib import Path
import streamlit as st
DEMO_DATA_PATH = Path("data/demo_updates.json")
payloads = json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8"))
st.write("# Company Signals Dashboard")
st.write("Raw synthetic source payloads")
st.dataframe(payloads)
Run and inspect the raw table
Streamlit runs app.py as a local server. The current PowerShell window stays occupied while the app is open.
- Switch back to the activated PowerShell window from earlier.
Before you run the app, do you expect all three records to fill the same table columns?
- Start the raw dashboard by running this command:
python -m streamlit run app.py
What does this command do?
Python starts the installed Streamlit module with app.py as the application script. Streamlit opens the local dashboard in your browser.
Your browser displays the Company Signals Dashboard heading. Below it, you should see the raw subtitle with a three-row table.
The table includes account_name, profile_name, message, caption, posted_at, timestamp, engagement, and engagement_count. Each row leaves blank cells where its source uses different field names.
That fragmented table is the intended shortfall. It proves the raw payloads cannot support one reliable company-update feed yet.
Dashboard not loading?
- Confirm that app.py is inside company-signals-dashboard.
- Confirm that demo_updates.json is inside the data folder.
- Open the local address printed in PowerShell if the browser does not open automatically.
Ask for help with debugging why the raw Streamlit dashboard does not load.
You have your first company signals dashboard running with visible table behavior. Next, you'll turn those fragmented fields into one consistent schema.
Normalize Every Update
Your Streamlit dashboard now exposes the fragmented columns inside both fixture shapes. Those columns cannot support one reliable company feed.
In this step, you'll add an adapter function that translates each payload into a shared schema. Every update will have the same five fields.
In this step, get ready to:
- Load the synthetic fixtures through a reusable function.
- Translate X and Instagram fields into one normalized schema.
- Display the newest updates first in five shared columns.
Create the adapter layer
The fixture loader gives your app one place to read the training data. The adapter converts every source shape into the fields your dashboard expects.
- Switch back to app.py in Notepad.
- Below DEMO_DATA_PATH = Path("data/demo_updates.json"), paste this code:
def load_demo_payloads():
return json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8"))
def normalize_demo_payloads(payloads):
updates = []
for payload in payloads:
if payload["source"] == "X":
updates.append(
{
"company": payload["account_name"],
"source": payload["source"],
"title": payload["message"],
"published_at": payload["posted_at"],
"engagement": payload["engagement"],
}
)
elif payload["source"] == "Instagram":
updates.append(
{
"company": payload["profile_name"],
"source": payload["source"],
"title": payload["caption"],
"published_at": payload["timestamp"],
"engagement": payload["engagement_count"],
}
)
return sorted(updates, key=lambda item: item["published_at"], reverse=True)
What does this code do?
- The load_demo_payloads() function reads the project-authored JSON fixture from data/demo_updates.json.
- The updates list stores each translated record.
- The X branch maps account_name, message, and posted_at into shared fields.
- The Instagram branch maps profile_name, caption, and timestamp into those same fields.
- The final sorted() call orders the updates by published_at in descending order.
Render the normalized feed
The display now needs to pass the fixtures through the adapter. Its table should receive the normalized records as its sole data source.
- In app.py, select everything from payloads = json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8")) through st.dataframe(payloads).
- Replace the selected block by pasting this code:
st.write("# Company Signals Dashboard")
st.write("Normalize source-specific updates into one local monitoring feed.")
updates = normalize_demo_payloads(load_demo_payloads())
st.dataframe(updates, width="stretch", hide_index=True)
How does the display pipeline work?
- The first st.write() call keeps the dashboard heading.
- The second st.write() call describes the normalized monitoring feed.
- The load_demo_payloads() call reads the fixture records.
- The normalize_demo_payloads() call converts those records into the shared schema.
- The st.dataframe() call renders the normalized updates list.
- The width="stretch" setting fills the available page width.
- The hide_index=True setting removes the extra row-number column.
- Save app.py by pressing Ctrl+S.
Before you return to the dashboard, do you think any source-specific columns will survive the adapter?
- Return to the browser tab from earlier.
Streamlit reruns the app. You'll see three rows with exactly five headers: company, source, title, published_at, and engagement.
Still seeing fragmented columns?
Make sure the new updates = normalize_demo_payloads(load_demo_payloads()) line replaced the old payloads assignment.
Check that both functions appear above the dashboard display code in app.py.
Help me find why my normalized Streamlit table still shows source-specific columns.
Confirm the shared schema
The row order proves that the adapter sorts by publication time. The column set proves that both fixture shapes now follow one contract.
- Check that Northstar Labs appears in the first row.
- Check that Cedar Analytics appears in the last row.
- Confirm that account_name no longer appears as a column.
- Confirm that profile_name no longer appears as a column.
You have solved the core data problem. Both source shapes now produce one ordered feed that the rest of your dashboard can reuse.
✔️ Awesome, I've got everything!
Your adapter is working. Your dashboard now shows the normalized five-field feed.
ⓧ I'd like to double check the full code
import json
from pathlib import Path
import streamlit as st
DEMO_DATA_PATH = Path("data/demo_updates.json")
def load_demo_payloads():
return json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8"))
def normalize_demo_payloads(payloads):
updates = []
for payload in payloads:
if payload["source"] == "X":
updates.append(
{
"company": payload["account_name"],
"source": payload["source"],
"title": payload["message"],
"published_at": payload["posted_at"],
"engagement": payload["engagement"],
}
)
elif payload["source"] == "Instagram":
updates.append(
{
"company": payload["profile_name"],
"source": payload["source"],
"title": payload["caption"],
"published_at": payload["timestamp"],
"engagement": payload["engagement_count"],
}
)
return sorted(updates, key=lambda item: item["published_at"], reverse=True)
st.write("# Company Signals Dashboard")
st.write("Normalize source-specific updates into one local monitoring feed.")
updates = normalize_demo_payloads(load_demo_payloads())
st.dataframe(updates, width="stretch", hide_index=True)
This file keeps fixture loading separate from normalization. The display receives only records that follow the shared schema.
Your adapter now gives every demo record the same shape. Next, you'll connect that schema to live public uploads through the YouTube Data API v3.
Connect Live YouTube Updates
Your normalized Streamlit dashboard now turns two fixture shapes into one consistent feed. The next goal is to bring current public channel updates into that same table.
Demo fixtures prove the interface. A live REST API call proves the dashboard can collect recent updates without authorization from every monitored channel.
In this step, get ready to:
- Enable YouTube Data API v3 and create a restricted API key.
- Add a live adapter that retrieves public channel uploads.
- Switch between demo fixtures and live YouTube updates.
Create and restrict your API key
The YouTube Data API v3 provides documented endpoints for public channel data. You need a Google Cloud project with the API enabled before the dashboard can send requests.
- Sign in to Google Cloud Console with your Google Account.
- Create a Google Cloud project if you need a dedicated project for this dashboard.
- Select the Google Cloud project you want to use.
- Use the console search to find YouTube Data API v3.
- Select the matching API result.
- Click Enable.
What did enabling the API change?
Your selected project can now send requests to YouTube Data API v3. Those requests consume the project's API quota.
The next action creates a live credential. Keep the API key private because it authorizes requests against your project's quota.
The restriction screen can be fiddly to find. Stay on the selected project's credential flow so the key belongs to the project where you enabled the API.
- Return to the Credentials page.
- Click Create credentials.
- Select API key.
- Add an API restriction that allows only YouTube Data API v3.
- Click Create.
- Store the new key in your password manager.
That credential is ready for controlled YouTube requests. It stays outside your project files throughout this build.
Unable to create the restricted key?
Confirm that the selected project has YouTube Data API v3 enabled. Check that your Google Account can create credentials in that project.
Ask for help with the exact screen where you are stuck: help me create a Google Cloud API key restricted to YouTube Data API v3.
Add the YouTube adapter
The live adapter needs two endpoints. The first resolves a channel handle to its uploads playlist. The second retrieves recent items from that playlist.
- Switch back to app.py in Notepad.
- Replace the import and configuration section at the top of app.py with this code:
import hashlib
import json
from pathlib import Path
import requests
import streamlit as st
CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"
PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
DEMO_DATA_PATH = Path("data/demo_updates.json")
DEFAULT_HANDLE = "@GoogleDevelopers"
What does this setup do?
- The hashing import creates a fingerprint of the private key for the request path.
- The Requests library sends the two HTTP requests.
- The URL constants keep both API endpoints in one place.
- The default handle gives the live mode a public channel to test immediately.
- Save app.py.
- Return to the dashboard tab from earlier.
Streamlit reruns the script. You should still see the normalized demo table without an import failure.
Did the dashboard stop rerunning?
Confirm that the activated virtual environment contains the dependencies from requirements.txt. Check the spelling of both new import lines.
If the import still fails, help me diagnose the failed imports in app.py.
Channel handles arrive as multiline text. The parser removes blank lines and duplicate handles before any network request begins.
- In app.py, find the end of normalize_demo_payloads().
- Add this function below normalize_demo_payloads().
def parse_handles(raw_handles):
handles = (line.strip() for line in raw_handles.splitlines())
return tuple(dict.fromkeys(handle for handle in handles if handle))
Why clean the handles first?
A clean tuple gives the request function one predictable collection to process. Duplicate handles would repeat the same API calls.
- Save app.py.
- Return to the dashboard tab.
You should see the demo rows rerender successfully. This confirms the parser has valid Python syntax.
Seeing a syntax problem near the parser?
Check that all three lines sit outside normalize_demo_payloads(). The new function must begin at the left edge of the file.
For help checking the placement, help me fix the parse_handles function in app.py.
The request function follows each channel's uploads playlist. It converts every returned video into the same five-key schema already used by the demo adapter.
- Add this function directly below parse_handles().
def fetch_youtube_updates(_api_key, key_fingerprint, handles):
_ = key_fingerprint
updates = []
notices = []
for handle in handles:
try:
channel_response = requests.get(CHANNELS_URL, params={"part": "snippet,contentDetails", "forHandle": handle, "key": _api_key}, timeout=10)
channel_response.raise_for_status()
channels = channel_response.json().get("items", [])
if not channels:
notices.append(f"No public channel matched {handle}.")
continue
channel = channels[0]
company = channel["snippet"]["title"]
uploads_playlist = channel["contentDetails"]["relatedPlaylists"]["uploads"]
playlist_response = requests.get(PLAYLIST_ITEMS_URL, params={"part": "snippet,contentDetails", "playlistId": uploads_playlist, "maxResults": 3, "key": _api_key}, timeout=10)
playlist_response.raise_for_status()
for item in playlist_response.json().get("items", []):
snippet = item.get("snippet", {})
content_details = item.get("contentDetails", {})
updates.append({"company": company, "source": "YouTube", "title": snippet.get("title", "Untitled update"), "published_at": content_details.get("videoPublishedAt", snippet.get("publishedAt", "")), "engagement": "Not requested"})
except Exception as exc:
notices.append(f"{handle}: {exc}")
return sorted(updates, key=lambda item: item["published_at"], reverse=True), notices
How does the adapter work?
- The first request uses forHandle to find the public channel.
- The channel response provides contentDetails.relatedPlaylists.uploads for the second request.
- The playlist request retrieves up to three recent items.
- Each item becomes a normalized update with company, source, title, publication time, and engagement fields.
- Readable notices preserve request failures without crashing the dashboard.
- Save app.py.
- Return to the dashboard tab.
You should see the demo feed rerun normally. The live adapter is now available to the interface without changing the existing demo result.
Did the request function break the rerun?
Check that the complete function sits above the first st.write call. Confirm that every line inside the function uses consistent indentation.
For a focused comparison, help me debug fetch_youtube_updates in app.py.
Switch between demo and live data
The interface now needs a mode control and masked credential input. Both paths finish with the same normalized updates list, so the table remains source-independent.
- In app.py, find the first st.write call near the bottom.
- Replace everything from that line through the existing st.dataframe call with this interface code:
st.write("# Company Signals Dashboard")
st.write("Normalize source-specific updates into one local monitoring feed.")
mode = st.radio("Data mode", ["Demo fixtures", "Live YouTube"], horizontal=True)
if mode == "Demo fixtures":
updates = normalize_demo_payloads(load_demo_payloads())
else:
api_key = st.text_input("YouTube API key", type="password")
raw_handles = st.text_area("YouTube handles, one per line", value=DEFAULT_HANDLE)
handles = parse_handles(raw_handles)
if api_key and handles:
key_fingerprint = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
updates, notices = fetch_youtube_updates(api_key, key_fingerprint, handles)
if notices:
st.write("API notices:", notices)
else:
updates = []
st.write("Enter an API key and at least one handle to load live updates.")
st.dataframe(updates, width="stretch", hide_index=True)
What does the interface change?
- The mode selector keeps the credential-free fixture path available.
- The password input masks the private key on screen.
- The multiline handle field supports one or more public channels.
- The key fingerprint provides a stable identifier without displaying the original key.
- The final table receives the same normalized schema from either mode.
- Save app.py.
- Switch back to the activated PowerShell window from earlier.
- Press Ctrl+C if the previous Streamlit server is still running.
Before you restart the app, do you expect the demo table or the live credential form to appear first?
- Start the updated dashboard by running this command:
python -m streamlit run app.py
What does this command do?
This starts the local Streamlit server with app.py as the application script. Your browser opens the updated dashboard.
You should see Demo fixtures selected first. The three normalized fixture rows remain visible.
Does the updated dashboard fail to open?
Confirm that PowerShell still shows the activated .venv environment. Check the terminal output for the line number that stopped the script.
Share that line number without sharing your API key: help me debug why the updated Streamlit dashboard does not open.
The first live request can take a few seconds while the dashboard resolves the channel and retrieves its uploads. The password field keeps the key masked in the browser.
Before you switch modes, do you expect the public uploads to fit the same five columns as the demo rows?
- Select Live YouTube under Data mode.
- Enter your private key in the YouTube API key field.
- Keep @GoogleDevelopers in the handles field.
You should see recent public uploads from the channel in the normalized table. If the request is rejected, you should see a readable API notices: message instead of a crashed app.
No live rows in the table?
Confirm that the key is restricted to YouTube Data API v3. Check that the selected Google Cloud project has the API enabled.
Confirm that the handle begins with @ and contains no extra spaces.
For help interpreting the notice without exposing the key, help me diagnose the YouTube API notice in my dashboard.
✔️ Awesome, I've got everything!
Your demo mode still works. Your live mode can now retrieve public YouTube uploads through a masked credential field.
- Save app.py one final time.
ⓧ I'd like to double check the full code
Compare your complete app.py with this reference. Update any differing line before saving the file.
import hashlib
import json
from pathlib import Path
import requests
import streamlit as st
CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"
PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
DEMO_DATA_PATH = Path("data/demo_updates.json")
DEFAULT_HANDLE = "@GoogleDevelopers"
def load_demo_payloads():
return json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8"))
def normalize_demo_payloads(payloads):
updates = []
for payload in payloads:
if payload["source"] == "X":
updates.append({"company": payload["account_name"], "source": payload["source"], "title": payload["message"], "published_at": payload["posted_at"], "engagement": payload["engagement"]})
elif payload["source"] == "Instagram":
updates.append({"company": payload["profile_name"], "source": payload["source"], "title": payload["caption"], "published_at": payload["timestamp"], "engagement": payload["engagement_count"]})
return sorted(updates, key=lambda item: item["published_at"], reverse=True)
def parse_handles(raw_handles):
handles = (line.strip() for line in raw_handles.splitlines())
return tuple(dict.fromkeys(handle for handle in handles if handle))
def fetch_youtube_updates(_api_key, key_fingerprint, handles):
_ = key_fingerprint
updates = []
notices = []
for handle in handles:
try:
channel_response = requests.get(CHANNELS_URL, params={"part": "snippet,contentDetails", "forHandle": handle, "key": _api_key}, timeout=10)
channel_response.raise_for_status()
channels = channel_response.json().get("items", [])
if not channels:
notices.append(f"No public channel matched {handle}.")
continue
channel = channels[0]
company = channel["snippet"]["title"]
uploads_playlist = channel["contentDetails"]["relatedPlaylists"]["uploads"]
playlist_response = requests.get(PLAYLIST_ITEMS_URL, params={"part": "snippet,contentDetails", "playlistId": uploads_playlist, "maxResults": 3, "key": _api_key}, timeout=10)
playlist_response.raise_for_status()
for item in playlist_response.json().get("items", []):
snippet = item.get("snippet", {})
content_details = item.get("contentDetails", {})
updates.append({"company": company, "source": "YouTube", "title": snippet.get("title", "Untitled update"), "published_at": content_details.get("videoPublishedAt", snippet.get("publishedAt", "")), "engagement": "Not requested"})
except Exception as exc:
notices.append(f"{handle}: {exc}")
return sorted(updates, key=lambda item: item["published_at"], reverse=True), notices
st.write("# Company Signals Dashboard")
st.write("Normalize source-specific updates into one local monitoring feed.")
mode = st.radio("Data mode", ["Demo fixtures", "Live YouTube"], horizontal=True)
if mode == "Demo fixtures":
updates = normalize_demo_payloads(load_demo_payloads())
else:
api_key = st.text_input("YouTube API key", type="password")
raw_handles = st.text_area("YouTube handles, one per line", value=DEFAULT_HANDLE)
handles = parse_handles(raw_handles)
if api_key and handles:
key_fingerprint = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
updates, notices = fetch_youtube_updates(api_key, key_fingerprint, handles)
if notices:
st.write("API notices:", notices)
else:
updates = []
st.write("Enter an API key and at least one handle to load live updates.")
st.dataframe(updates, width="stretch", hide_index=True)
What should the complete file contain?
The complete file preserves the normalized demo adapter. It adds handle parsing, live retrieval, readable notices, and a masked mode interface.
Your dashboard now combines a guaranteed demo path with a live public API connector. Next, you will add local filtering and five-minute caching so interactive reruns protect your quota.
Add Filtering and Quota-Aware Caching
Your dashboard now switches between normalized demo updates and live updates from the YouTube Data API v3.
Streamlit reruns the script after widget changes. Caching keeps repeated live requests off the network for five minutes.
A local source filter changes the visible rows without triggering another API call.
In this step, get ready to:
- Cache live YouTube responses for five minutes.
- Filter normalized updates by source.
- Confirm demo mode still works without credentials.
Cache live YouTube requests
Every widget interaction reruns your script. A data cache lets repeated calls reuse the same returned updates until the five-minute lifetime expires.
- In app.py, find this function header:
def fetch_youtube_updates(_api_key, key_fingerprint, handles):
Why start at this function?
This function contains the live network requests. Caching its returned updates prevents ordinary interface reruns from repeating those requests.
- Add the cache decorator directly above the function header so the two lines look like this:
@st.cache_data(ttl=300, show_spinner=False)
def fetch_youtube_updates(_api_key, key_fingerprint, handles):
How does the cache protect quota?
- The ttl=300 setting keeps each returned result for 300 seconds.
- The show_spinner=False setting avoids displaying an extra cache spinner.
- The leading underscore on _api_key excludes the plaintext key from Streamlit's cache-key hashing.
- The key_fingerprint value lets different keys produce different cache entries.
- For each handle, the app calls channels.list once.
- It also calls playlistItems.list once.
- Each call costs one quota unit.
- Save app.py.
- Return to the activated PowerShell window from earlier.
Before you start the updated app, which load do you expect to take longer: the first live fetch or a repeated fetch with the same inputs?
- Start the updated dashboard by running this command:
python -m streamlit run app.py
What does this command do?
This starts app.py through the Streamlit package installed in your active virtual environment. Keep the PowerShell window running while you test the browser interface.
Your private key stays masked in the browser. The app does not write it to a project file.
- Select Live YouTube under Data mode.
- Enter your private key in the YouTube API key field.
- Keep @GoogleDevelopers in the handles field.
- Wait for the table to display recent uploads.
- Select Demo fixtures under Data mode.
- Select Live YouTube again within five minutes.
- Re-enter the same private key if the key field is empty.
That is your quota guard working. The same live rows return from the five-minute cache without the first fetch's network wait.
Does the repeated fetch still feel slow?
Confirm you added the decorator directly above fetch_youtube_updates().
Use the same API key value. Keep the same handle so the cache inputs remain unchanged.
help me check why my Streamlit cache is not reusing the YouTube response
Filter updates by source
The cache controls network access. The source filter works on the normalized updates already held in memory.
- In app.py, find the final table line:
st.dataframe(updates, width="stretch", hide_index=True)
What does the current line do?
The current line sends every normalized update directly to the table. It has no local selection step.
- Replace that final line with this filtering block:
sources = sorted({update["source"] for update in updates})
selected_sources = st.multiselect(
"Sources",
sources,
default=sources,
)
filtered_updates = [
update for update in updates if update["source"] in selected_sources
]
st.write(f"Showing {len(filtered_updates)} update(s).")
st.dataframe(filtered_updates, width="stretch", hide_index=True)
What does this filtering block do?
- The sources set derives the available choices from the current normalized rows.
- The st.multiselect() widget starts with every available source selected.
- The filtered_updates list keeps rows whose source remains selected.
- The visible count reports how many rows reach the final table.
- Save app.py.
- Return to the dashboard in your browser.
- Select Demo fixtures under Data mode.
You should see X and Instagram selected in the Sources filter. The page should show three updates.
Missing a source from the filter?
Confirm the filter block appears after the mode logic. The updates list must exist before the app derives its sources.
Check that the final table uses filtered_updates instead of updates.
help me debug why my Streamlit source filter is missing choices or not changing the table
Use this check to compare the completed app.py file with the exact cumulative version.
✔️ Awesome, I've got everything!
Your saved file now includes five-minute caching and local source filtering.
ⓧ I'd like to double check the full code
import hashlib
import json
from pathlib import Path
import requests
import streamlit as st
CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"
PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"
DEMO_DATA_PATH = Path("data/demo_updates.json")
DEFAULT_HANDLE = "@GoogleDevelopers"
def load_demo_payloads():
return json.loads(DEMO_DATA_PATH.read_text(encoding="utf-8"))
def normalize_demo_payloads(payloads):
updates = []
for payload in payloads:
if payload["source"] == "X":
updates.append(
{
"company": payload["account_name"],
"source": payload["source"],
"title": payload["message"],
"published_at": payload["posted_at"],
"engagement": payload["engagement"],
}
)
elif payload["source"] == "Instagram":
updates.append(
{
"company": payload["profile_name"],
"source": payload["source"],
"title": payload["caption"],
"published_at": payload["timestamp"],
"engagement": payload["engagement_count"],
}
)
return sorted(updates, key=lambda item: item["published_at"], reverse=True)
def parse_handles(raw_handles):
handles = (line.strip() for line in raw_handles.splitlines())
return tuple(dict.fromkeys(handle for handle in handles if handle))
@st.cache_data(ttl=300, show_spinner=False)
def fetch_youtube_updates(_api_key, key_fingerprint, handles):
_ = key_fingerprint
updates = []
notices = []
for handle in handles:
try:
channel_response = requests.get(
CHANNELS_URL,
params={
"part": "snippet,contentDetails",
"forHandle": handle,
"key": _api_key,
},
timeout=10,
)
channel_response.raise_for_status()
channels = channel_response.json().get("items", [])
if not channels:
notices.append(f"No public channel matched {handle}.")
continue
channel = channels[0]
company = channel["snippet"]["title"]
uploads_playlist = channel["contentDetails"]["relatedPlaylists"]["uploads"]
playlist_response = requests.get(
PLAYLIST_ITEMS_URL,
params={
"part": "snippet,contentDetails",
"playlistId": uploads_playlist,
"maxResults": 3,
"key": _api_key,
},
timeout=10,
)
playlist_response.raise_for_status()
for item in playlist_response.json().get("items", []):
snippet = item.get("snippet", {})
content_details = item.get("contentDetails", {})
updates.append(
{
"company": company,
"source": "YouTube",
"title": snippet.get("title", "Untitled update"),
"published_at": content_details.get(
"videoPublishedAt", snippet.get("publishedAt", "")
),
"engagement": "Not requested",
}
)
except Exception as exc:
notices.append(f"{handle}: {exc}")
return (
sorted(updates, key=lambda item: item["published_at"], reverse=True),
notices,
)
st.write("# Company Signals Dashboard")
st.write("Normalize source-specific updates into one local monitoring feed.")
mode = st.radio(
"Data mode",
["Demo fixtures", "Live YouTube"],
horizontal=True,
)
if mode == "Demo fixtures":
updates = normalize_demo_payloads(load_demo_payloads())
else:
api_key = st.text_input("YouTube API key", type="password")
raw_handles = st.text_area(
"YouTube handles, one per line",
value=DEFAULT_HANDLE,
)
handles = parse_handles(raw_handles)
if api_key and handles:
key_fingerprint = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
updates, notices = fetch_youtube_updates(api_key, key_fingerprint, handles)
if notices:
st.write("API notices:", notices)
else:
updates = []
st.write("Enter an API key and at least one handle to load live updates.")
sources = sorted({update["source"] for update in updates})
selected_sources = st.multiselect(
"Sources",
sources,
default=sources,
)
filtered_updates = [
update for update in updates if update["source"] in selected_sources
]
st.write(f"Showing {len(filtered_updates)} update(s).")
st.dataframe(filtered_updates, width="stretch", hide_index=True)
What should match?
This reference includes the cached live function and the final local filtering block. Compare its spacing and line breaks with your saved file.
Verify caching and credential-free demo mode
- In PowerShell, stop the current Streamlit server by pressing Ctrl+C.
Before you restart the completed app, do you expect both demo sources to begin selected in the new filter?
- Restart the completed dashboard by running this command:
python -m streamlit run app.py
What does this final run prove?
This run loads the completed file in a fresh server process. It verifies that caching and filtering work together from a clean start.
The first live fetch can pause briefly while the app requests the channel and its uploads. That pause confirms the live path is doing real network work.
- Select Live YouTube under Data mode.
- Enter your restricted key in the YouTube API key field.
- Keep @GoogleDevelopers in the handles field.
- Wait for recent uploads to appear in the table.
You should see YouTube selected under Sources. The visible count should match the number of displayed uploads.
Before you clear the source filter, do you expect the app to wait for another network response?
- Clear the YouTube selection from the Sources filter.
The table updates immediately. You should see Showing 0 update(s).
- Select YouTube in the Sources filter again.
The live rows return immediately during the five-minute cache window. The filter changes only local data.
The last check proves your dashboard remains useful when no live credential is available.
- Clear the contents of the YouTube API key field.
- Select Demo fixtures under Data mode.
You have closed the loop on the completed dashboard. It shows three normalized demo updates without an API key.
Secret mission
Add Freshness Labels
Company feeds become easier to scan when every timestamp carries an immediate recency signal. Add display-only freshness labels while keeping your normalized adapter schema intact.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your dashboard ready, pause its running process, or remove its resources. Live requests to the YouTube Data API v3 use API quota, although the local files have no ongoing cost.
Resources you used:
- The company-signals-dashboard folder containing .venv, app.py, requirements.txt, and data/demo_updates.json.
- The local Streamlit server running in your activated Windows PowerShell workspace.
- The restricted API key in the Google Cloud project used by your live connector.
- The enabled YouTube Data API v3 access in that Google Cloud project.
Keep everything running
No action is needed. Choose this option if you plan to keep monitoring company updates or extend the dashboard.
- Keep the company-signals-dashboard folder so your virtual environment, fixtures, and dashboard code remain available.
- Keep the API key restricted to YouTube Data API v3.
- Keep the API key private by leaving it out of app.py and every other project file.
- Use the run command from Step 5 whenever you return to the activated PowerShell workspace.
Pause - I'll come back to this later
Stop the running dashboard to free its local resources. Your project files, virtual environment, and cloud configuration remain available for your next session.
- Stop the Streamlit server by pressing Ctrl+C in the activated PowerShell window from earlier.
The dashboard process is now stopped, so it cannot make live API requests.
- Close the PowerShell window after the server stops.
- Return to the existing company-signals-dashboard folder in PowerShell when you are ready to continue.
- Reactivate the existing .venv with the activation command from Step 1.
- Start the dashboard with the run command from Step 5.
- Select Demo fixtures when you want to use the dashboard without entering the API key.
Delete - I don't want to use this again
Remove the cloud access and local workspace when you are finished with the dashboard. Permanent deletion is final, so a separate backup is the safest home for anything you may want later.
- Stop the Streamlit server by pressing Ctrl+C in the activated PowerShell window from earlier.
Remove Cloud Access
- Return to the Google Cloud Console from earlier.
- Select the Google Cloud project used by your dashboard.
- Open the Credentials page.
- Select the restricted API key created for the live connector.
- Use the page's deletion control for the selected key.
- Confirm the API key deletion.
That closes the credential path. The deleted key can no longer authenticate requests from your dashboard.
The remaining API setup depends on whether the Google Cloud project exists only for this dashboard or supports other work.
Delete the dedicated project
Deleting a dedicated project removes the enabled API configuration with the rest of that project's resources.
- Open the project settings page for the selected Google Cloud project.
- Use the project deletion control.
- Confirm the deletion with the project identifier requested by Google Cloud.
The project should no longer appear as an active Google Cloud project.
Keep a shared project
A shared project may contain resources that belong to other work. Remove this dashboard's API access while leaving those resources intact.
- Open the list of enabled APIs for the selected Google Cloud project.
- Select YouTube Data API v3.
- Use the API page's disable control.
- Confirm that YouTube Data API v3 no longer appears in the project's enabled API list.
Remove Local Files
- Press the Windows key to open the search bar.
- Type File Explorer and press Enter.
- Return to the folder that contains company-signals-dashboard.
- Select the company-signals-dashboard folder.
- Press Shift+Delete to permanently remove the selected folder.
- Confirm the deletion in the Windows prompt.
You should no longer see the company-signals-dashboard folder in File Explorer.
Nice Work!
Nice Work!
Outstanding work! Your local Streamlit company signals dashboard now turns source-specific updates into one consistent feed.
You've learned how to:
- Build a local Streamlit dashboard that switches from a credential-free demo feed to live company updates.
- Create adapter functions that convert mismatched X plus Instagram payloads into one normalized schema.
- Use the YouTube Data API v3 for quota-aware retrieval through local source filtering plus a five-minute cache.
- Secret Mission: Add display-only freshness labels from ISO 8601 timestamps without changing the normalized ingestion schema.
Ready to quiz yourself?