【20251111】視聴履歴Ver

できること

New!【視聴履歴を残せる】

・複数のURLを制限対象に登録できる

・URLごとに制限時間を決められる

・制限時間を後から変更できる

・同じサイトを複数のタブで開いていた場合、カウントが倍加するのを防げる

・日付が変わったらリセットする

できないこと

・問題を出題できない

Free ICON

FlatIcon【https://www.flaticon.com/free-icon/timer_1407089

Directory Structure

ytlimiter
|---manifest.json
|---background.js
|---chart.js
|---icon128.png
|---popup.html
|---popup.css
|---popup.js
|---option.html
|---option.js
|---option.css

ストレージの中身

{
  "site": [
    { "url": "https://www.youtube.com/", "limit": 2700 },
    { "url": "https://x.com/", "limit": 1800 }
  ],
  "watchData": {
    "https://www.youtube.com/": {
      "date": "2025-11-10",
      "seconds": 1230,
      "history": { "2025-11-07": 1000, "2025-11-08": 1200, "2025-11-09": 800 }
    },
    "https://x.com/": {
      "date": "2025-11-07",
      "seconds": 600,
      "history": { "2025-11-07": 900, "2025-11-08": 1400, "2025-11-09": 1300 }
    }
  }
}

manifest.json

{
    "manifest_version": 3,
    "name": "TIME LIMITER",
    "version": "1.0",
    "description": "ウェブサイトの視聴時間を制限する拡張機能",
    "permissions": [
        "tabs",
        "storage",
        "alarms",
        "scripting",
        "activeTab"
    ],
    "host_permissions": [
        "<all_urls>"
    ],
    "background": {
        "service_worker": "background.js"
    },
    "action": {
        "default_icon": "icon128.png",
        "default_popup": "popup.html",
        "default_title": "TIME LIMITER"
    },
    "options_page": "option.html",
    "web_accessible_resources": [{
        "resources": ["chart.js"],
        "matches": ["<all_urls>"]
    }],
    "icons": {
        "128": "icon128.png"
    }
}

popup.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>タイムリミッター</title>
        <link rel="stylesheet" href="popup.css" />
    </head>
    <body>
        <h3>新しいサイトを追加</h3>
        <div id="wrap">
            <button id="btnSetCurrentTabURL">このページのURLを入力</button>
            <p id="desc">またはURLを直接入力↓</p>
            <input id="inputURL" type="text" placeholder="例)https://www.youtube.com/" />
            <div id="wrapTimelimit">
                <div>制限時間</div>
                <input id="inputTimelimit" type="number" placeholder="例)60" />
                <span>分</span>
            </div>
            <button id="btnAdd">追加</button>
        </div>
        <hr />
        <ul id="listURL"></ul>
        <hr />

        <a class="btnOptionPage" href="option.html" target="_blank">履歴を見る</a>
        <script src="popup.js"></script>
    </body>
</html>

popup.css

* {
    font-size: 10px;
}

h3 {
    font-size: 10px;
}

/* wrap */
#wrap {
    text-align: center;
}

#wrapTimelimit {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 0.5rem 0;
}

#wrapTimelimit div {
    width: 30%;
    margin: 0 auto;
    font-size: 10px;
    text-align: left;
}

#wrapTimelimit span {
    display: block;
    width: 15%;
    font-size: 10px;
}

/* btn */
#btnSetCurrentTabURL {
    width: 100%;
    padding: 0.5rem;
    background: #806cd4;
    border: none;
    border-radius: 3px;
    color: #fff;
    font-size: 10px;
}

#btnAdd {
    width: 100%;
    padding: 0.5rem;
    background: #806cd4;
    border: none;
    border-radius: 3px;
    color: #fff;
    font-size: 10px;
}

.btnDel {
    display: block;
    width: 100%;
    margin: 0.5rem 0;
    padding: 0.5rem 0;
    background: #806cd4;
    border: none;
    border-radius: 3px;
    color: #fff;
    font-size: 10px;
}

.btnOptionPage {
    color: #806cd4;
}

/* input */
#inputURL {
    display: block;
    padding: 0.5rem;
    background: #f2f2f2;
    border: none;
    border-radius: 3px;
    font-size: 10px;
}

#inputTimelimit {
    display: block;
    max-width: 45%;
    padding: 0.5rem;
    background: #f2f2f2;
    border: none;
    border-radius: 3px;
    font-size: 10px;
}


/* list */
ul,
li {
    list-style-type: none;
    margin: 0;
    padding: 0;
}

.itemURL {
    margin: 0.5rem 0;
    padding: 0.5rem;
    background: #f2f2f2;
    border-radius: 3px;
}

.itemURL strong {
    font-size: 10px;
}

.itemURL .wrapLimit {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 0.5rem 0;
}

.itemURL .wrapLimit div {
    width: 30%;
    font-size: 10px;
}

.itemURL .wrapLimit input {
    max-width: 45%;
    padding: 0.5rem;
    background: #fff;
    border: 1px solid #c0c0c0;
    border-radius: 3px;
    font-size: 10px;
}

.itemURL .wrapLimit span {
    display: block;
    width: 15%;
    font-size: 10px;
    text-align: center;
}

.itemURL .wrapRemain {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 0.5rem 0;
}

.itemURL .wrapRemain p {
    margin: 0;
    font-size: 10px;
}

.itemURL .wrapRemain span {
    font-size: 10px;
}



/* other */
#desc {
    margin: 3px;
    color: #777;
    font-size: 9px;
    text-align: left;
}

popup.js

async function getSite() {
    const data = await chrome.storage.local.get(["site"]);
    return data.site || [];
}

async function getWatchData() {
    const data = await chrome.storage.local.get(["watchData"]);
    return data.watchData || {};
}


// ================================================
// 今日の日付を日本時間(JST)で"YYYY-MM-DD"形式で取得
// ================================================
function getTodayJST() {
    // new Date()で得られる時刻はUTCなので日本時間より9時間遅れている
    const now = new Date();
    const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
    return jst.toISOString().split("T")[0];
}


// ===============
// サイト一覧の描画
// ===============
async function renderSite() {
    // <ul>タグの中身を空に設定
    const listURL = document.getElementById("listURL");
    listURL.innerHTML = "";
    // ストレージのデータを取得する
    const sites = await getSite();
    const watchData = await getWatchData();
    if (sites.length === 0) {
        listURL.innerHTML = "<p>登録されたサイトがありません</p>";
        return;
    }
    // 今日の日付
    const today = getTodayJST();
    // サイトごとに処理
    for (const site of sites) {
        // 視聴時間と残り時間を計算
        const seconds = watchData[site.url]?.date === today ? watchData[site.url].seconds : 0;
        const remain = Math.max(0, site.limit - seconds);
        const remainMin = Math.floor(remain / 60);
        // <li>タグを作成
        const li = document.createElement("li");
        li.className = "itemURL";
        li.innerHTML = `
            <div>
                <strong>${site.url}</strong>
            </div>
            <div class="wrapLimit">
                <div>制限時間</div>
                <input class="inputLimit" type="number" data-url="${site.url}" value="${Math.floor(site.limit / 60)}" />
                <span>分</span>
            </div>
            <div class="wrapRemain">
                <p>残り時間</p>
                <span id="remain-${btoa(site.url)}">${remainMin}分</span>
            </div>
            <button class="btnDel" data-url="${site.url}">削除</button>
        `;
        listURL.appendChild(li);
    }
    deleteSite();
    updateTimeLimit();
}

// ========================
// サイトの削除
// ========================
function deleteSite() {
    const btnDels = document.querySelectorAll(".btnDel");
    btnDels.forEach(btn => {
        btn.addEventListener("click", async () => {
            const url = btn.dataset.url;
            // site配列から削除
            let sites = await getSite();
            sites = sites.filter(s => s.url !== url);
            await chrome.storage.local.set({ site: sites });
            // watchDataからも削除
            let watchData = await getWatchData();
            if (watchData[url]) {
                delete watchData[url];
                await chrome.storage.local.set({ watchData });
            }
            renderSite();
        });
    });
}

// =============
// 制限時間の変更
// =============
function updateTimeLimit() {
    const inputLimit = document.querySelectorAll(".inputLimit");
    inputLimit.forEach(input => {
        input.addEventListener("change", async () => {
            // フォームの値を取得
            const url = input.dataset.url;
            const newLimitMin = parseInt(input.value);
            if (isNaN(newLimitMin) || newLimitMin <= 0) {
                alert("有効な時間を入力してください");
                return;
            }
            let sites = await getSite();
            const target = sites.find(s => s.url === url);
            if (target) {
                target.limit = newLimitMin * 60;
            }
            await chrome.storage.local.set({ site: sites });
            renderSite();
        });

    });
}

// ===========
// サイトの追加
// ===========
async function addSite() {
    // フォームの値を取得
    const inputURL = document.getElementById("inputURL");
    const inputTimelimit = document.getElementById("inputTimelimit");
    const url = inputURL.value.trim();
    const limitMin = parseInt(inputTimelimit.value);
    // バリデーション
    if (!url || isNaN(limitMin) || limitMin <= 0) {
        alert("URLと有効な制限時間(分)を入力してください");
        return;
    }
    // バリデーション
    let sites = await getSite();
    if (sites.find(s => s.url === url)) {
        alert("このサイトはすでに登録されています");
        return;
    }
    // ストレージの更新
    sites.push({ url, limit: limitMin * 60 });
    await chrome.storage.local.set({ site: sites });
    // フォームを空にする
    inputURL.value = "";
    inputTimelimit.value = "";
    renderSite();
}
document.getElementById("btnAdd").addEventListener("click", addSite);


// =================================
// btnSetCurrentURLを押したときの処理
// =================================
async function setCurrentTabURL() {
    try {
        // アクティブタブの取得
        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (tab && tab.url) {
            const url = new URL(tab.url);
            const baseURL = url.origin + '/';
            document.getElementById("inputURL").value = baseURL;
        }
    } catch (err) {
        console.error("URL取得エラー: ", err);
    }
}
document.getElementById("btnSetCurrentTabURL").addEventListener("click", setCurrentTabURL);


// =============
// 残り時間の更新(5秒おき)
// =============
setInterval(async () => {
    const sites = await getSite();
    const watchData = await getWatchData();
    const today = getTodayJST();
    // サイトごとに処理
    for (const site of sites) {
        const seconds = watchData[site.url]?.date === today ? watchData[site.url].seconds : 0;
        const remain = Math.max(0, site.limit - seconds);
        const remainMin = Math.floor(remain / 60);
        const span = document.getElementById(`remain-${btoa(site.url)}`);
        if (span) {
            span.textContent = `${remainMin}分`;
        }
    }
}, 5000);


// =======
// 初期表示
// =======
renderSite();

background.js

// ==========================
// データの取得(ストレージから)
// ==========================
async function getSite() {
    const data = await chrome.storage.local.get(["site"]);
    return data.site || [
        { url: "https://a.com/", limit: 2700 },
        { url: "https://b.com/", limit: 1800 }
    ];
}

async function getWatchData() {
    const data = await chrome.storage.local.get(["watchData"]);
    return data.watchData || {};
}


// ===============================================
// 今日の日付を日本時間(JST)で"YYYY-MM-DD"形式で取得
// ===============================================
function getTodayJST() {
    const now = new Date();
    const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
    return jst.toISOString().split("T")[0];
}


// =============================
// サイトが制限対象であるかチェック
// =============================
async function isBlockedSite(siteURL) {
    const sites = await getSite();
    return sites.find(site => siteURL.startsWith(site.url));
}


// ===============
// 制限超過チェック
// ===============
async function isOverLimit(siteURL) {
    const watchData = await getWatchData();
    // サイトが制限対象であるか
    const site = await isBlockedSite(siteURL);
    if (!site) return false;
    // 制限時間を超えているか
    const today = getTodayJST();
    const seconds = watchData[site.url]?.date === today ? watchData[site.url].seconds : 0;
    return seconds >= site.limit;
}


// ========================
// アラート表示+リダイレクト
// chrome.scripting.executeScript({});を任意ドメインで使う場合はmanifest.jsonで"host_permissions":["<all_urls>"]が必要
// ========================
async function showAlertAndRedirect(tabId, siteURL) {
    try {
        await chrome.scripting.executeScript({
            target: { tabId },
            func: (url) => { alert(`${url}の制限時間を超えました!`); },
            args: [siteURL]
        });
    } catch (err) {
        console.error("アラートエラー: ", err);
    }
    // 0.5秒待ってリダイレクト
    setTimeout(() => {
        chrome.tabs.update(tabId, { url: "about:blank" });
    }, 500);
}


// ==================
// タブ更新時のチェック
// ==================
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
    // ページが完全に読み込まれてから処理する
    //if (changeInfo.status !== "complete") return;
    // 制限対象のサイトであるか確認
    if (!tab.url) return;
    const site = await isBlockedSite(tab.url);
    if (!site) return;
    
    // 時間超過していればリダイレクト
    if (await isOverLimit(site.url)) {
        await showAlertAndRedirect(tabId, site.url);
    }
});


// ============================
// 5秒ごとに定期監視して時間を加算
// ============================
setInterval(async() => {
    // chrome.tabs.query({});を使う場合はmanifest.jsonに"permissions":["tabs"]が必要
    const tabs = await chrome.tabs.query({});
    const watchData = await getWatchData();
    const today = getTodayJST();
    
    // SetはJSに最初から用意されているオブジェクトであり、「重複しない値だけを保持する配列」
    // 例) const s = new Set();
    // s.add("https://1.com/");
    // s.add("https://2.com/");
    // s.add("https://1.com/");
    // console.log(s);  --->  {'https://1.com/', 'https://2.com/'}

    // 同じサイトを複数タブ開いているときにカウントが倍加するのを防ぐ
    const siteURLs = new Set();
    for (const tab of tabs) {
        if (!tab.url) continue;
        const site = await isBlockedSite(tab.url);
        if (site) {
            siteURLs.add(site.url);
        }
    }

    // タブごとに処理
    for (const siteURL of siteURLs) {
        // 制限対象のサイトでなければコンティニュー
        const site = await isBlockedSite(siteURL);
        if (!site) continue;
        // データ初期化
        if (!watchData[site.url]) {
            watchData[site.url] = { data: today, seconds: 0, history: {} };
        }
        // 日付が変わったら履歴に追加してリセット
        const wData = watchData[site.url];
        if (wData.date !== today) {
            if (!wData.history) wData.history = {};
            wData.history[wData.date] = wData.seconds;
            wData.date = today;
            wData.seconds = 0;
        }
        // 制限時間を超えていた場合
        if (await isOverLimit(site.url)) {
            const closeTabs = tabs.filter(t => t.url && t.url.startsWith(site.url));
            for (const closeTab of closeTabs) {
                await showAlertAndRedirect(closeTab.id, site.url);
            }
            continue;
        }
        
        // 5秒加算
        wData.seconds += 5;
    }
    // 更新したデータを保存
    await chrome.storage.local.set({ watchData });
}, 5000);

option.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>視聴時間履歴</title>
  <link rel="stylesheet" href="option.css" />
  <!-- Chrome拡張機能はContent Security Policyにより外部スクリプトの読み込みが禁止されているのでCDNの読み込みができない -->
  <!-- なのでscript src="https://cdn.jsdelivr.net/npm/chart.js" で読み込むのではなくローカルで読み込む必要がある -->
  <!-- またmanifest.jsonにもweb_accessible_resourcesを追記する必要がある -->
  <script src="chart.js"></script>
</head>
<body>
  <h1>視聴時間履歴</h1>
  <div id="wrapChart">読み込み中...</div>
  <div id="wrapHistory">読み込み中...</div>
  <script src="option.js"></script>
</body>
</html>

option.js

async function getWatchData() {
    const data = await chrome.storage.local.get(["watchData"]);
    return data.watchData || {};
}


// ===================
// 秒 → ◯時間◯分に変換
// ===================
function formatTime(seconds) {
    const hour = Math.floor(seconds / 3600);
    const min = Math.floor((seconds % 3600) / 60);
    return hour > 0 ? `${hour}時間${min}分` : `${min}分`;
}


// ===============
// 棒グラフ描画関数
// ===============
function drawBarChart(ctx, labels, values, title) {
    new Chart(ctx, {
        type: "bar",
        data: {
            labels,
            datasets: [{
                labels: "視聴時間(分)",
                data: values,
                backgroundColor: "rgba(128, 108, 212, 0.6)",
                borderColor: "rgba(128, 108, 212, 1)",
                borderWidth: 1,
            }]
        },
        options: {
            plugins: {
                title: {
                    display: true,
                    text: title,
                    font: { size: 16 }
                },
                legend: { display: false }
            },
            scales: {
                y: {
                    beginAtZero: true,
                    title: { display: true, text: "分" }
                }
            }
        }
    });
}


// =============
// 視聴履歴の描画
// =============
async function renderHistory() {
    const wrapHistory = document.getElementById("wrapHistory");
    const wrapChart = document.getElementById("wrapChart");
    const watchData = await getWatchData();
    
    // Object.keysはオブジェクトのキー一覧を取得する関数
    // 例)const obj = { "A": {win:10, lose:20, draw:30}, "B": {win:40, lose:50, draw:60} };
    // Object.keys(obj)  --->  ["A", "B"]
    if (!Object.keys(watchData).length) {
        wrapHistory.textContent = "データがありません";
        return;
    }

    let html = "";
    wrapChart.innerHTML = "";

    // Object.entriesはオブジェクトを配列に変換する関数
    // 例)const obj = { A:1, B:2 };
    // Object.entries(obj)  ---> [ [A:1], [B:2] ]
    for (const [url, data] of Object.entries(watchData)) {
        const history = data.history || {};
        const entries = Object.entries(history).sort((a, b) => b[0].localeCompare(a[0]));

        // === 文字データ(グラフではなく)の描画 ===
        //html += `<h2>${url}の視聴時間</h2>`;
        html += `<div class="tableHead"><div>日付</div><div>視聴時間</div></div>`;

        // 履歴がある場合のみ出力 
        if (entries.length > 0) {
            for (const [date, sec] of entries) {
                html += `<div class="tableContents"><div>${date}</div><div>${formatTime(sec)}</div></div>`;
            }
        }

        // 今日のデータを表示 
        html += `<div class="tableContents"><div>${data.date}</div><div>${formatTime(data.seconds)}</div></div>`;


        // === 棒グラフの描画 ===
        const merged = { ...history };
        if (data.date && typeof data.seconds === "number") {
            merged[data.date] = (merged[data.date] || 0) + data.seconds; // 同じ日付があっても合算
        }
        const sortedEntries = Object.entries(merged).sort((a, b) => a[0].localeCompare(b[0]));
        const labels = sortedEntries.map(([date]) => date);
        const values = sortedEntries.map(([_, sec]) => Math.floor(sec / 60));
        
        const div = document.createElement("div");
        //div.innerHTML = `<h2>${url}</h2><canvas></canvas>`;
        div.innerHTML = `<canvas></canvas>`;
        wrapChart.appendChild(div);
        const canvas = div.querySelector("canvas");
        drawBarChart(canvas, labels, values, url);
    }
    wrapHistory.innerHTML = html;
}

// 初期表示
renderHistory()

option.css

 body {
     padding: 20px;
     font-family: sans-serif;
     font-size: 14px;
     background: #f7f7f7;
 }

 h1 {
     margin-bottom: 20px;
     font-size: 24px;
 }

 h2 {
     margin-top: 30px;
     color: #555;
     font-size: 16px;
     font-weight: 700;
 }

 .chart-container {
     background: #fff;
     border-radius: 10px;
     padding: 20px;
     margin-bottom: 30px;
     box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
 }

 /*  */
 #wrapChart div {
    margin: 1rem 0;
    padding: 1rem;
    background: #fff;
    border: 1px solid #ccc;
    border-radius: 10px;
 }
 canvas {
     width: 100%;
     max-width: 600px;
     height: 300px;
 }


 /* table */
 .tableHead {
    display: flex;
 }
 .tableHead div {
    width: calc( 25% - 2px );
    padding: 0.5rem;
    margin: 1px;
    text-align: center;
    background: #fff;
    border-bottom: 1px solid #806cd4;
 }
 .tableContents {
    display: flex;
 }
 .tableContents div {
    width: calc( 25% - 2px );
    margin: 1px;
    padding: 0.5rem;
    text-align: center;
    background: #fff;
 }
BACK