訂閱 YouTube 頻道很容易,取消訂閱卻像在整理一抽屜「以後可能會用到」的傳輸線:一條一條處理,耐心很快就沒電了。這篇會帶你用 Google YouTube Data API、OAuth、SvelteKit 與 Tailwind CSS,做一個能列出訂閱清單、逐一取消訂閱,也能批次處理的簡單工具。

你會看懂 Google Cloud 專案與 OAuth 要怎麼設定、如何分頁讀取自己的訂閱,以及 subscriptions.delete 為什麼只能一次刪除一筆。最後也會看到這個範例的限制,避免把測試程式直接當成萬用生產工具——那就像拿濾紙煮泡麵,理論上有創意,實務上不太妙。

先了解這個工具能做什麼

YouTube Data API 的 subscriptions.list 可以在授權後讀取目前帳號的訂閱項目;subscriptions.delete 則可以刪除指定的訂閱項目。這兩個 API 都需要授權,因為它們會讀取或修改你的帳號資料。

API 沒有「把全部訂閱一次刪光」的單一端點,所以批次取消的做法其實是:先取得整份清單,再逐筆呼叫刪除 API。這也是本文範例的核心流程。

為什麼想做批次取消訂閱?

當訂閱數累積到上百個,首頁推薦很容易變成一場「我當初為什麼訂這個」的考古活動。很多頻道可能只看過一次,或只是某支影片很有趣,結果就被永久留在訂閱清單裡。

一個一個點退訂當然可以,只是點到十幾個之後,滑鼠可能比你更想離職。於是我決定自己做一個小工具,讓畫面先列出訂閱頻道,再選擇要取消的項目。

開發前先規劃

這個範例採用純前端的 SvelteKit 應用,畫面直接呼叫 YouTube Data API,不另外建立後端。登入與授權則交給 Google OAuth 2.0。

這種架構適合拿來學習 API 串接與做個人測試工具;如果要服務其他使用者,還要另外處理 OAuth 用戶端設定、憑證保護、錯誤處理與部署安全性。純前端不是「什麼都不用管」,只是把要管的事情搬到瀏覽器旁邊。

畫面樣式使用 Tailwind CSS,原因很簡單:先把功能做出來,再讓按鈕不要長得像 1998 年的內部系統。

準備 Google API

開始寫程式前,要先在 Google Cloud Console 建立專案,並在專案中啟用 YouTube Data API v3。

建立 Google Cloud 專案

登入 Google Cloud Console,建立一個新專案。

進入剛建立的專案,選擇「API 與服務」>「啟用 API 和服務」。

搜尋「YouTube Data API v3」,選取後按下啟用。

設定 OAuth

接著在同一個專案建立 OAuth 用戶端憑證,並完成 OAuth 同意畫面需要的基本資訊。

設定 OAuth 同意畫面時,依照 Google Cloud Console 顯示的欄位填寫應用程式名稱與聯絡資訊。

在 OAuth 用戶端設定中加入本機開發用的重新導向 URI。這個 URI 必須和程式送出的 redirect_uri 完全一致,包含通訊協定、網域、連接埠與路徑。

本文的範例需要存取使用者的 YouTube 訂閱,因此不是單純讀取公開影片資料。測試期間可能需要把自己的帳號加入測試使用者;若要提供給一般使用者,則要依 Google 當時的 OAuth 驗證要求完成設定。Cloud Console 的介面與審核規則會變動,遇到畫面不同時,以目前官方說明為準。

建立 SvelteKit 專案

先建立一個新的 SvelteKit 專案:

pnpm create svelte@latest youtube-app
cd youtube-app
pnpm install

接著用編輯器開啟專案:

code .

pnpm create svelte@latest 會依目前版本提供互動式選項;這篇文章的畫面與程式碼是當時範例,若產生器詢問的選項不同,選擇適合一般 TypeScript 專案的設定即可。

安裝 Tailwind CSS

原始範例使用 Tailwind CSS 與 DaisyUI 快速處理樣式。下面保留當時的安裝方式;Tailwind 的整合方式會隨版本更新,實際操作時請以 Tailwind CSS 官方文件 為準。

pnpm install -D tailwindcss postcss autoprefixer
pnpm dlx tailwindcss init -p
pnpm install -D daisyui@latest

修改 svelte.config.js

import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	kit: {
		adapter: adapter()
	},
	preprocess: vitePreprocess()
};

export default config;

修改 tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
	content: ['./src/**/*.{html,js,svelte,ts}'],
	theme: {
		extend: {}
	},
	plugins: [require('daisyui')]
};

建立 src/app.css

@tailwind base;
@tailwind components;
@tailwind utilities;

再建立或修改 src/routes/+layout.svelte,匯入剛才的 CSS:

// src/routes/+layout.svelte(script 區塊)
import '../app.css';
<!-- src/routes/+layout.svelte(template 區塊) -->
<slot />

製作登入頁面

先在首頁放一個按鈕,讓使用者啟動 Google OAuth 授權流程。以下程式使用 OAuth 2.0 的 JavaScript 用戶端流程,client_idredirect_uri 請換成自己在 Google Cloud Console 設定的值。

修改 src/routes/+page.svelte

// src/routes/+page.svelte(script 區塊)
const redirectUri = 'http://localhost:5173/oauth2callback';
const clientId = 'your_client_id';
const oauthEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth';

function oauthSignIn() {
	const params = new URLSearchParams({
		client_id: clientId,
		redirect_uri: redirectUri,
		response_type: 'token',
		scope: 'https://www.googleapis.com/auth/youtubepartner'
	});

	window.location.href = `${oauthEndpoint}?${params.toString()}`;
}
<!-- src/routes/+page.svelte(template 區塊) -->
<h1 class="m-4 text-3xl">Mass Unsubscribe YouTube</h1>
<button class="btn btn-warning" on:click={oauthSignIn}>
	使用 Google 帳號登入
</button>

這裡的 scope 代表應用程式要求的權限範圍。OAuth 同意畫面會顯示應用程式要求的權限,使用者同意後才會回到指定頁面。不要把 client_id、API Key 或重新導向 URI 寫成「大家共用的神秘常數」;每個 Cloud 專案都應該使用自己的設定,API Key 也應設定適當的限制。

顯示訂閱頻道

登入完成後,接著在 src/routes/oauth2callback/+page.svelte 讀取存取權杖,呼叫訂閱 API。subscriptions.listmaxResults 上限是 50,因此不能只請求一次就假設拿到全部資料;要依 nextPageToken 持續取得下一頁。

// src/routes/oauth2callback/+page.svelte(script 區塊)
import { onMount } from 'svelte';
import { page } from '$app/stores';

type Subscription = {
	id: string;
	snippet: {
		title: string;
		description: string;
		resourceId: {
			channelId: string;
		};
		thumbnails?: {
			default?: {
				url: string;
			};
		};
	};
};

const apiKey = 'your_api_key';
let subscriptions: Subscription[] = [];
let accessToken: string | null = null;

function getAccessToken(hash: string) {
	const params = new URLSearchParams(hash.replace(/^#/, ''));
	return params.get('access_token');
}

async function loadSubscriptions(pageToken = '') {
	const params = new URLSearchParams({
		part: 'snippet',
		mine: 'true',
		maxResults: '50',
		key: apiKey
	});

	if (pageToken) {
		params.set('pageToken', pageToken);
	}

	const response = await fetch(
		`https://youtube.googleapis.com/youtube/v3/subscriptions?${params}`,
		{
			headers: {
				Authorization: `Bearer ${accessToken}`
			}
		}
	);

	if (!response.ok) {
		throw new Error(`讀取訂閱失敗:${response.status}`);
	}

	const data = await response.json();
	subscriptions = [...subscriptions, ...(data.items ?? [])];

	if (data.nextPageToken) {
		await loadSubscriptions(data.nextPageToken);
	}
}

onMount(async () => {
	accessToken = getAccessToken($page.url.hash);

	if (!accessToken) {
		return;
	}

	try {
		await loadSubscriptions();
	} catch (error) {
		console.error(error);
	}
});
<!-- src/routes/oauth2callback/+page.svelte(template 區塊) -->
<h1 class="mt-4 text-center text-4xl">
	你目前有 {subscriptions.length} 個訂閱
</h1>

<ul>
	{#each subscriptions as subscription}
		<li class="m-12 flex flex-col border-2 p-4">
			<a
				href={`https://www.youtube.com/channel/${subscription.snippet.resourceId.channelId}`}
				target="_blank"
				rel="noreferrer"
				class="self-center"
			>
				{#if subscription.snippet.thumbnails?.default?.url}
					<img
						src={subscription.snippet.thumbnails.default.url}
						alt={subscription.snippet.title}
						class="h-24"
					/>
				{/if}
			</a>
			<h2 class="p-2 text-center text-xl">{subscription.snippet.title}</h2>
			<p>{subscription.snippet.description}</p>
		</li>
	{/each}
</ul>

程式會把每一頁的 items 接到 subscriptions 陣列,直到 API 不再回傳 nextPageToken。這個判斷很重要;少了它,你看到的可能只是前 50 個訂閱,後面的頻道會在角落默默喊:「我也是訂閱啊。」

取消單一頻道訂閱

每個訂閱項目都有自己的 id。取消訂閱時,把這個 ID 傳給 subscriptions.delete,並用 DELETE 方法呼叫:

async function unsubscribe(id: string) {
	const response = await fetch(
		`https://youtube.googleapis.com/youtube/v3/subscriptions?id=${id}`,
		{
			method: 'DELETE',
			headers: {
				Authorization: `Bearer ${accessToken}`
			}
		}
	);

	if (!response.ok) {
		throw new Error(`取消訂閱失敗:${response.status}`);
	}

	subscriptions = subscriptions.filter((subscription) => subscription.id !== id);
}

成功後,把項目從畫面上的陣列移除,就不用重新整理整頁。API 成功刪除時會回傳成功狀態;若權杖過期、權限不足或 ID 不正確,則應保留項目並顯示錯誤,而不是假裝它已經消失。

批次取消訂閱

批次功能就是重複呼叫剛才的 unsubscribe。範例用兩秒間隔排程,避免在短時間內送出大量請求:

async function unsubscribeAll() {
	const confirmed = window.confirm('確定要取消目前清單中的所有訂閱嗎?');

	if (!confirmed) {
		return;
	}

	for (const subscription of [...subscriptions]) {
		await unsubscribe(subscription.id);
		await new Promise((resolve) => setTimeout(resolve, 2000));
	}
}

這段程式的意思是「目前載入清單中的所有項目」,不是替你取消 YouTube 帳號裡某個尚未載入的隱藏清單。按下按鈕前,最好先確認畫面上的清單與數量;批次刪除不像咖啡加糖,按錯了不一定能加回來。

實際應用還應加入載入狀態、逐筆錯誤訊息、停止按鈕,以及避免重複點擊的防護。這些不會改變 API 的基本用法,但會讓工具從「能動」進步到「敢讓自己使用」。

啟動與測試

啟動本機開發伺服器:

pnpm dev

打開 http://localhost:5173/,依序完成以下流程:

  1. 按下「使用 Google 帳號登入」。
  2. 選擇要授權的 Google 帳號。
  3. 閱讀 OAuth 權限提示並完成授權。
  4. 回到訂閱清單後,先測試取消一個不再觀看的頻道。
  5. 確認畫面與 YouTube 帳號狀態都符合預期,再考慮批次處理。

加入測試帳號

如果 OAuth 同意畫面仍在測試狀態,要先把用來登入的 Google 帳號加入測試使用者。

開啟本機網址

選擇帳號

處理安全警告

未完成 Google 驗證的測試應用程式,可能會出現安全警告。這不代表可以隨便按下繼續;請確認應用程式名稱、開發者資訊與要求的權限,確定是自己的測試專案再繼續。

確認授權

查看訂閱清單

點擊單一項目的取消按鈕,就會呼叫刪除 API;批次按鈕則會依序處理目前載入的項目。第一次測試請先取消一個項目,別讓手指一上工就直接進入全自動模式。

這個範例的限制

這是學習 API 串接的示範,不是可以直接交給陌生使用者登入的完整產品。至少要留意以下幾點:

  • OAuth 用戶端設定與同意畫面可能隨 Google Cloud 政策更新。
  • API Key 不應毫無限制地暴露;應在 Google Cloud Console 設定適合的限制。
  • 存取權杖有生命週期,過期後需要重新處理授權。
  • 批次刪除是多次請求,可能遇到網路錯誤、配額限制或單筆失敗。
  • 取消訂閱是具破壞性的操作,正式工具應提供清楚的確認、錯誤回報與中止機制。

完整程式碼可參考 youtube-app,測試頁面則是原文的 /unsubscribe 頁面

如果你想把這個範例升級成正式服務,建議先重新閱讀 Google 的 OAuth 與 YouTube Data API 文件,再依目前的登入流程與安全要求調整。API 會更新,教學文章也會變老;只有咖啡冷掉這件事,倒是一直很穩定。

參考資料