内部データを用いた取得テスト

This commit is contained in:
Keisuke Hirata 2026-04-08 01:07:10 +09:00
commit a05af1a208
15 changed files with 1098 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

41
esbuild.config.mjs Normal file
View File

@ -0,0 +1,41 @@
import * as esbuild from "esbuild";
import { cpSync, mkdirSync } from "fs";
const common = {
bundle: true,
sourcemap: true,
target: "es2020",
};
async function build(browser) {
const outdir = `dist/${browser}`;
mkdirSync(outdir, { recursive: true });
await Promise.all([
esbuild.build({
...common,
entryPoints: ["src/content/index.ts"],
outfile: `${outdir}/content/index.js`,
format: "iife",
}),
esbuild.build({
...common,
entryPoints: ["src/background/service-worker.ts"],
outfile: `${outdir}/background/service-worker.js`,
format: "iife",
}),
esbuild.build({
...common,
entryPoints: ["src/injected/page-script.ts"],
outfile: `${outdir}/injected/page-script.js`,
format: "iife",
}),
]);
cpSync(`manifest.${browser}.json`, `${outdir}/manifest.json`);
}
await build("chrome");
await build("firefox");
console.log("Build complete.");

23
manifest.chrome.json Normal file
View File

@ -0,0 +1,23 @@
{
"manifest_version": 3,
"name": "YT Playlist Features",
"version": "0.1.0",
"description": "Extract and work with YouTube playlist data",
"permissions": ["storage"],
"background": {
"service_worker": "background/service-worker.js"
},
"content_scripts": [
{
"matches": ["*://www.youtube.com/*"],
"js": ["content/index.js"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["injected/page-script.js"],
"matches": ["*://www.youtube.com/*"]
}
]
}

29
manifest.firefox.json Normal file
View File

@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "YT Playlist Features",
"version": "0.1.0",
"description": "Extract and work with YouTube playlist data",
"permissions": ["storage"],
"background": {
"scripts": ["background/service-worker.js"]
},
"content_scripts": [
{
"matches": ["*://www.youtube.com/*"],
"js": ["content/index.js"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["injected/page-script.js"],
"matches": ["*://www.youtube.com/*"]
}
],
"browser_specific_settings": {
"gecko": {
"id": "yt-playlist-features@example.com",
"strict_min_version": "133.0"
}
}
}

531
package-lock.json generated Normal file
View File

@ -0,0 +1,531 @@
{
"name": "yt-playlist-features",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yt-playlist-features",
"version": "0.1.0",
"dependencies": {
"webextension-polyfill": "^0.12.0"
},
"devDependencies": {
"@types/webextension-polyfill": "^0.12.1",
"esbuild": "^0.25.0",
"typescript": "^5.7.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/webextension-polyfill": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.5.tgz",
"integrity": "sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/webextension-polyfill": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.12.0.tgz",
"integrity": "sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==",
"license": "MPL-2.0"
}
}
}

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "yt-playlist-features",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "node esbuild.config.mjs",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/webextension-polyfill": "^0.12.1",
"esbuild": "^0.25.0",
"typescript": "^5.7.0"
},
"dependencies": {
"webextension-polyfill": "^0.12.0"
}
}

View File

@ -0,0 +1,27 @@
import browser from "webextension-polyfill";
import type { Message } from "../shared/messages";
import { savePlaylist, getPlaylist } from "../shared/storage";
const LOG_PREFIX = "[yt-playlist-features:bg]";
browser.runtime.onMessage.addListener(
async (message: unknown, _sender: browser.Runtime.MessageSender) => {
const msg = message as Message;
if (msg.type === "PLAYLIST_EXTRACTED") {
console.log(
LOG_PREFIX,
`Saving playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
);
await savePlaylist(msg.data);
return;
}
if (msg.type === "GET_PLAYLIST") {
const data = await getPlaylist(msg.playlistId);
return { type: "PLAYLIST_RESPONSE", data } satisfies Message;
}
},
);
console.log(LOG_PREFIX, "Service worker started.");

220
src/content/extractor.ts Normal file
View File

@ -0,0 +1,220 @@
import type {
PlaylistData,
PlaylistMetadata,
PlaylistVideo,
Thumbnail,
} from "../types/playlist";
/* eslint-disable @typescript-eslint/no-explicit-any */
const LOG = "[yt-playlist-features]";
export function parsePlaylistData(raw: any): PlaylistData | null {
try {
const metadata = extractMetadata(raw);
if (!metadata) {
console.warn(LOG, "Could not extract metadata");
return null;
}
const videoListContents = findVideoListContents(raw);
if (!videoListContents) {
console.warn(LOG, "Could not find video list");
return null;
}
const videos: PlaylistVideo[] = [];
let hasMore = false;
for (const item of videoListContents) {
if (item.playlistVideoRenderer) {
const video = parseVideo(item.playlistVideoRenderer);
if (video) videos.push(video);
}
if (item.continuationItemRenderer) {
hasMore = true;
}
}
return {
metadata,
videos,
extractedAt: new Date().toISOString(),
isComplete: !hasMore,
extractedCount: videos.length,
};
} catch (e) {
console.error(LOG, "Failed to parse playlist data:", e);
return null;
}
}
function extractMetadata(raw: any): PlaylistMetadata | null {
// Extract playlist ID from URL as fallback
const playlistId = extractPlaylistId(raw);
if (!playlistId) return null;
// Title from metadata.playlistMetadataRenderer
const metadataRenderer = raw?.metadata?.playlistMetadataRenderer;
const title = metadataRenderer?.title ?? "";
// Sidebar has primary and secondary info
const sidebarItems =
raw?.sidebar?.playlistSidebarRenderer?.items ?? [];
const primaryInfo = sidebarItems[0]?.playlistSidebarPrimaryInfoRenderer;
const secondaryInfo = sidebarItems[1]?.playlistSidebarSecondaryInfoRenderer;
// Stats from primary info (e.g. "87 本の動画", "視聴回数 1,234 回", "最終更新日...")
const stats = primaryInfo?.stats ?? [];
const videoCount = parseVideoCount(stats[0]);
const viewCountText = extractText(stats[1]) || null;
const lastUpdatedText = extractText(stats[2]) || null;
// Description from pageHeaderViewModel or primary info
const pageHeaderVM =
raw?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel;
const description =
extractText(pageHeaderVM?.description?.descriptionPreviewViewModel?.description) ||
extractText(primaryInfo?.description) ||
"";
// Owner from secondary info
const ownerRenderer =
secondaryInfo?.videoOwner?.videoOwnerRenderer;
const ownerRun = ownerRenderer?.title?.runs?.[0];
const ownerEndpoint =
ownerRun?.navigationEndpoint?.browseEndpoint;
// Thumbnails from pageHeaderViewModel heroImage or primary info
const thumbnails = extractPlaylistThumbnails(pageHeaderVM, primaryInfo);
return {
playlistId,
title,
description,
videoCount,
totalDurationText: null, // not available in current structure
viewCountText,
lastUpdatedText,
thumbnails,
owner: {
name: ownerRun?.text ?? "",
channelId: ownerEndpoint?.browseId ?? "",
url: ownerRun?.navigationEndpoint?.commandMetadata
?.webCommandMetadata?.url ?? "",
},
privacy: "unknown",
};
}
function extractPlaylistId(raw: any): string | null {
// Try microformat
const microformat =
raw?.microformat?.microformatDataRenderer?.urlCanonical;
if (microformat) {
const match = microformat.match(/[?&]list=([^&]+)/);
if (match) return match[1];
}
// Try from appindexing link
const appLink =
raw?.metadata?.playlistMetadataRenderer?.androidAppindexingLink;
if (appLink) {
const match = appLink.match(/[?&]list=([^&]+)/);
if (match) return match[1];
}
// Fallback to URL
const url = new URL(window.location.href);
return url.searchParams.get("list");
}
function findVideoListContents(raw: any): any[] | null {
const tabs =
raw?.contents?.twoColumnBrowseResultsRenderer?.tabs;
if (!tabs?.length) return null;
const tabContent = tabs[0]?.tabRenderer?.content;
const sectionContents =
tabContent?.sectionListRenderer?.contents;
if (!sectionContents?.length) return null;
const itemSection =
sectionContents[0]?.itemSectionRenderer?.contents;
if (!itemSection?.length) return null;
return itemSection[0]?.playlistVideoListRenderer?.contents ?? null;
}
function parseVideo(renderer: any): PlaylistVideo | null {
const videoId = renderer.videoId;
if (!videoId) return null;
const bylineRun = renderer.shortBylineText?.runs?.[0];
const bylineEndpoint =
bylineRun?.navigationEndpoint?.browseEndpoint;
const lengthSeconds = renderer.lengthSeconds
? parseInt(renderer.lengthSeconds, 10)
: null;
return {
videoId,
title: extractText(renderer.title),
index: parseInt(extractText(renderer.index) || "0", 10),
durationSeconds: lengthSeconds,
durationText: extractText(renderer.lengthText) || null,
thumbnails: renderer.thumbnail?.thumbnails ?? [],
channel: {
name: bylineRun?.text ?? "",
channelId: bylineEndpoint?.browseId ?? "",
url: bylineRun?.navigationEndpoint?.commandMetadata
?.webCommandMetadata?.url ?? "",
},
isPlayable: renderer.isPlayable !== false,
isLive:
renderer.badges?.some(
(b: any) =>
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
) ?? false,
};
}
function extractText(textObj: any): string {
if (!textObj) return "";
if (typeof textObj === "string") return textObj;
if (textObj.simpleText) return textObj.simpleText;
if (textObj.runs) {
return textObj.runs.map((r: any) => r.text).join("");
}
if (textObj.content) return extractText(textObj.content);
return "";
}
function extractPlaylistThumbnails(
pageHeaderVM: any,
primaryInfo: any,
): Thumbnail[] {
// Try heroImage from pageHeaderViewModel
const heroThumbnails =
pageHeaderVM?.heroImage?.contentPreviewImageViewModel?.image?.sources;
if (heroThumbnails?.length) {
return heroThumbnails.map((t: any) => ({
url: t.url ?? "",
width: t.width ?? 0,
height: t.height ?? 0,
}));
}
// Fallback to primary info thumbnail
return (
primaryInfo?.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail
?.thumbnails ??
primaryInfo?.thumbnailRenderer?.playlistCustomThumbnailRenderer?.thumbnail
?.thumbnails ??
[]
);
}
function parseVideoCount(textObj: any): number {
const text = extractText(textObj);
const match = text.replace(/,/g, "").match(/(\d+)/);
return match ? parseInt(match[1], 10) : 0;
}

61
src/content/index.ts Normal file
View File

@ -0,0 +1,61 @@
import browser from "webextension-polyfill";
import { onPlaylistPageReady, getPlaylistId } from "./navigation";
import { parsePlaylistData } from "./extractor";
import type { Message } from "../shared/messages";
const LOG_PREFIX = "[yt-playlist-features]";
// Track the last extracted playlist ID to avoid duplicate extractions
let lastExtractedId: string | null = null;
function injectPageScript(): void {
const script = document.createElement("script");
script.src = browser.runtime.getURL("injected/page-script.js");
script.onload = () => script.remove();
(document.head || document.documentElement).appendChild(script);
}
function handlePlaylistData(event: Event): void {
const detail = (event as CustomEvent).detail;
if (!detail) return;
let raw: any;
try {
raw = JSON.parse(detail);
} catch {
console.error(LOG_PREFIX, "Failed to parse ytInitialData JSON");
return;
}
const playlistData = parsePlaylistData(raw);
if (!playlistData) {
console.warn(LOG_PREFIX, "Could not extract playlist data from ytInitialData");
return;
}
console.log(
LOG_PREFIX,
`Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount} videos)`,
);
// Send to background service worker for storage
const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData };
browser.runtime.sendMessage(message).catch((err) => {
console.error(LOG_PREFIX, "Failed to send playlist data to background:", err);
});
}
// Listen for data from the injected page script
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
// Detect playlist page navigation and trigger extraction
onPlaylistPageReady(() => {
const playlistId = getPlaylistId();
if (!playlistId || playlistId === lastExtractedId) return;
lastExtractedId = playlistId;
// Small delay to ensure ytInitialData is updated after SPA navigation
setTimeout(() => {
injectPageScript();
}, 100);
});

38
src/content/navigation.ts Normal file
View File

@ -0,0 +1,38 @@
type NavigationCallback = () => void;
export function onPlaylistPageReady(callback: NavigationCallback): void {
// Check current page immediately
if (isPlaylistPage()) {
callback();
}
// YouTube's SPA navigation fires this custom event on completion
document.addEventListener("yt-navigate-finish", () => {
if (isPlaylistPage()) {
callback();
}
});
// Fallback: watch <title> changes as a signal of navigation
const titleEl = document.querySelector("title");
if (titleEl) {
const observer = new MutationObserver(() => {
if (isPlaylistPage()) {
callback();
}
});
observer.observe(titleEl, { childList: true });
}
}
function isPlaylistPage(): boolean {
const url = new URL(window.location.href);
return (
url.pathname === "/playlist" && url.searchParams.has("list")
);
}
export function getPlaylistId(): string | null {
const url = new URL(window.location.href);
return url.searchParams.get("list");
}

View File

@ -0,0 +1,11 @@
// Runs in the page's JS context (not isolated world).
// Reads ytInitialData and sends it to the content script via CustomEvent.
const data = (window as any).ytInitialData;
if (data) {
document.dispatchEvent(
new CustomEvent("__yt_playlist_ext_data", {
detail: JSON.stringify(data),
}),
);
}

6
src/shared/messages.ts Normal file
View File

@ -0,0 +1,6 @@
import type { PlaylistData } from "../types/playlist";
export type Message =
| { type: "PLAYLIST_EXTRACTED"; data: PlaylistData }
| { type: "GET_PLAYLIST"; playlistId: string }
| { type: "PLAYLIST_RESPONSE"; data: PlaylistData | null };

25
src/shared/storage.ts Normal file
View File

@ -0,0 +1,25 @@
import browser from "webextension-polyfill";
import type { PlaylistData } from "../types/playlist";
const STORAGE_PREFIX = "playlist:";
export async function savePlaylist(data: PlaylistData): Promise<void> {
await browser.storage.local.set({
[STORAGE_PREFIX + data.metadata.playlistId]: data,
});
}
export async function getPlaylist(
playlistId: string,
): Promise<PlaylistData | null> {
const key = STORAGE_PREFIX + playlistId;
const result = await browser.storage.local.get(key);
return (result[key] as PlaylistData) ?? null;
}
export async function getAllPlaylists(): Promise<PlaylistData[]> {
const all = await browser.storage.local.get(null);
return Object.entries(all)
.filter(([key]) => key.startsWith(STORAGE_PREFIX))
.map(([, value]) => value as PlaylistData);
}

51
src/types/playlist.ts Normal file
View File

@ -0,0 +1,51 @@
export interface Thumbnail {
url: string;
width: number;
height: number;
}
export interface PlaylistVideo {
videoId: string;
title: string;
/** 0-indexed position in the playlist */
index: number;
/** Duration in seconds; null if live or unknown */
durationSeconds: number | null;
/** Human-readable duration, e.g. "12:34" */
durationText: string | null;
thumbnails: Thumbnail[];
channel: {
name: string;
channelId: string;
url: string;
};
isPlayable: boolean;
isLive: boolean;
}
export interface PlaylistMetadata {
playlistId: string;
title: string;
description: string;
videoCount: number;
totalDurationText: string | null;
viewCountText: string | null;
lastUpdatedText: string | null;
thumbnails: Thumbnail[];
owner: {
name: string;
channelId: string;
url: string;
};
privacy: "public" | "unlisted" | "private" | "unknown";
}
export interface PlaylistData {
metadata: PlaylistMetadata;
videos: PlaylistVideo[];
/** ISO timestamp of extraction */
extractedAt: string;
/** Whether all videos were loaded or only the first page */
isComplete: boolean;
extractedCount: number;
}

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}