【20251109】シンプルVer

できること

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

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

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

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

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

できないこと

・問題を出題できない

・視聴履歴を残せない

Free ICON

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

Directory Structure

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

ストレージの中身

{
  "site": [
    { "url": "https://www.youtube.com/", "limit": 2700 },
    { "url": "https://x.com/", "limit": 1800 }
  ],
  "watchData": {
    "https://www.youtube.com/": { "date": "2025-11-07", "seconds": 1230 },
    "https://x.com/": { "date": "2025-11-07", "seconds": 600 }
  }
}

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>
        <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;
}

/* 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

// ストレージの中身は
// {
//     site: [
//         { url: "https://www.youtube.com/", limit: 2700 },
//         { url: "https://x.com/", limit: 1800 }
//     ],
//     watchData: {
//         "https://www.youtube.com/": { date: "2025-11-10", seconds: 1300 },
//         "https://x.com/": { date: "2025-11-10", seconds: 800 }
//     }
// }
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://www.youtube.com/", limit: 2700 },
        { url: "https://x.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 (!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 (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;
        }
        // 日付が変わったらリセット
        if (!watchData[site.url] || watchData[site.url].date !== today) {
            watchData[site.url] = { date: today, seconds: 0 };
        }
        // 5秒加算
        watchData[site.url].seconds += 5;
    }
    // 更新したデータを保存
    await chrome.storage.local.set({ watchData });
}, 5000);

manifest.json

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