From a05af1a208494afa2a6870092efc2f489249382d Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Apr 2026 01:07:10 +0900 Subject: [PATCH] =?UTF-8?q?=E5=86=85=E9=83=A8=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=82=92=E7=94=A8=E3=81=84=E3=81=9F=E5=8F=96=E5=BE=97=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + esbuild.config.mjs | 41 +++ manifest.chrome.json | 23 ++ manifest.firefox.json | 29 ++ package-lock.json | 531 +++++++++++++++++++++++++++++++ package.json | 18 ++ src/background/service-worker.ts | 27 ++ src/content/extractor.ts | 220 +++++++++++++ src/content/index.ts | 61 ++++ src/content/navigation.ts | 38 +++ src/injected/page-script.ts | 11 + src/shared/messages.ts | 6 + src/shared/storage.ts | 25 ++ src/types/playlist.ts | 51 +++ tsconfig.json | 15 + 15 files changed, 1098 insertions(+) create mode 100644 .gitignore create mode 100644 esbuild.config.mjs create mode 100644 manifest.chrome.json create mode 100644 manifest.firefox.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/background/service-worker.ts create mode 100644 src/content/extractor.ts create mode 100644 src/content/index.ts create mode 100644 src/content/navigation.ts create mode 100644 src/injected/page-script.ts create mode 100644 src/shared/messages.ts create mode 100644 src/shared/storage.ts create mode 100644 src/types/playlist.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..95ebdbd --- /dev/null +++ b/esbuild.config.mjs @@ -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."); diff --git a/manifest.chrome.json b/manifest.chrome.json new file mode 100644 index 0000000..912eb96 --- /dev/null +++ b/manifest.chrome.json @@ -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/*"] + } + ] +} diff --git a/manifest.firefox.json b/manifest.firefox.json new file mode 100644 index 0000000..2ed03a7 --- /dev/null +++ b/manifest.firefox.json @@ -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" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c714978 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cf60718 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts new file mode 100644 index 0000000..bfa3e02 --- /dev/null +++ b/src/background/service-worker.ts @@ -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."); diff --git a/src/content/extractor.ts b/src/content/extractor.ts new file mode 100644 index 0000000..c03cb49 --- /dev/null +++ b/src/content/extractor.ts @@ -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; +} diff --git a/src/content/index.ts b/src/content/index.ts new file mode 100644 index 0000000..309b490 --- /dev/null +++ b/src/content/index.ts @@ -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); +}); diff --git a/src/content/navigation.ts b/src/content/navigation.ts new file mode 100644 index 0000000..16367db --- /dev/null +++ b/src/content/navigation.ts @@ -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 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"); +} diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts new file mode 100644 index 0000000..38a0f32 --- /dev/null +++ b/src/injected/page-script.ts @@ -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), + }), + ); +} diff --git a/src/shared/messages.ts b/src/shared/messages.ts new file mode 100644 index 0000000..1442c65 --- /dev/null +++ b/src/shared/messages.ts @@ -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 }; diff --git a/src/shared/storage.ts b/src/shared/storage.ts new file mode 100644 index 0000000..9e69d5b --- /dev/null +++ b/src/shared/storage.ts @@ -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); +} diff --git a/src/types/playlist.ts b/src/types/playlist.ts new file mode 100644 index 0000000..d6f7101 --- /dev/null +++ b/src/types/playlist.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d1efccf --- /dev/null +++ b/tsconfig.json @@ -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"] +}