【20251110】問題出題Ver
できること
New【対象のURLを開くたびに問題が出題される】 <— background.jsのタブ更新関数でエラー発生し原因わからず
・複数の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
|---content.js
|---content.css
|---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: 1300 },
// "https://x.com/": { date: "2025-11-10", seconds: 800 }
// },
// quiz: [
// { question: "テキスト", choices: ["1","2","3","4"], answer: 2 },
// { question: "テキスト", choices: ["1","2","3","4"], answer: 3 },
// ]
// }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="btnQuizPage" 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;
}
.btnQuizPage {
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
// ストレージの中身は
// {
// 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 }
// },
// quiz: [
// { question: "テキスト", choices: ["1","2","3","4"], answer: 2 },
// { question: "テキスト", choices: ["1","2","3","4"], answer: 3 },
// ]
// }
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 || {};
}
async function getQuiz() {
const data = await chrome.storage.local.get(["quiz"]);
return data.quiz || [];
}
// ===============================================
// 今日の日付を日本時間(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);
}
// ===========
// クイズの表示
// ===========
async function showQuiz(tabId, siteURL) {
const quizs = await getQuiz();
if (quizs.length === 0) return null;
// ランダムに1問取得
const quiz = quizs[Math.floor(Math.random() * quizs.length)];
// クイズがない場合はスキップ
if (!quiz) return;
// content.jsにメッセージを送る
await chrome.tabs.sendMessage(tabId, { type: "SHOW_QUIZ", quiz });
// content.jsからのメッセージをリッスンする
return new Promise(resolve => {
chrome.runtime.onMessage.addListener(function listener(msg) {
if (msg.type === "QUIZ_RESULT") {
chrome.runtime.onMessage.removeListener(listener);
resolve(msg.correct);
}
});
});
}
// ==================
// タブ更新時のチェック
// ==================
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;
// クイズを出題して不正解なら閲覧禁止
const correct = await showQuiz(tabId, site.url);
if (!correct) {
await showAlertAndRedirect(tabId, site.url);
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);content.js
(async function() {
chrome.runtime.onMessage.addListener(async (msg) => {
if (msg.type === "SHOW_QUIZ") {
const quiz = msg.quiz;
const div = document.createElement("div");
div.className = "overlay";
div.innerHTML = `
<div class="wrapQuiz">
<h2>QUIZ</h2>
<p>${quiz.question}</p>
${quiz.choices.map((choice, index) =>`
<button class="btnQuiz" data-index="${index}">${choice}</button>
`).join("")}
</div>
`;
document.body.appendChild(div);
div.querySelectorAll(".btnQuiz").forEach(btn => {
btn.addEventListener("click", async () => {
const choice = parseInt(btn.dataset.index);
const correct = (choice === quiz.answer);
if (correct) {
alert("正解!閲覧を許可します");
div.remove();
chrome.runtime.sendMessage({ type: "QUIZ_RESULT", correct: true });
} else {
alert("不正解!もう一度やり直してください");
chrome.runtime.sendMessage({ type: "QUIZ_RESULT", correct: false });
}
});
});
}
});
})();content.css
/* クイズオーバーレイ全体 */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
color: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 999999;
}
/* クイズボックス */
.wrapQuiz {
background: #fff;
padding: 20px;
border-radius: 10px;
color: #333;
text-align: center;
max-width: 400px;
box-shadow: 0 0 10px rgba(255,255,255,0.2);
}
.wrapQuiz h2 {
font-size: 14px;
}
.wrapQuiz p {
margin: 1rem 0;
font-size: 16px;
}
/* ボタン */
.btnQuiz {
margin: 5px;
padding: 10px 20px;
background: #806cd4;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
}
.quiz-btn:hover {
background: #0056b3;
}
option.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>クイズ管理ページ</title>
<link rel="stylesheet" href="option.css" />
</head>
<body>
<h2>Quiz Form</h2>
<div class="wrapForm">
<p class="pQuestion">問題文</p>
<textarea id="question" placeholder="例: 1 + 1は?"></textarea>
<p class="pChoice">選択肢</p>
<div class="wrapChoice">
<input id="choice1" type="text" placeholder="選択肢1" />
<input id="choice2" type="text" placeholder="選択肢2" />
<input id="choice3" type="text" placeholder="選択肢3" />
<input id="choice4" type="text" placeholder="選択肢4" />
</div>
<p class="pAnswer">正解番号</p>
<input id="answer" type="number" min="1" max="4" />
<button id="btnAddQuiz">追加</button>
</div>
<hr />
<h3>Quiz List</h3>
<ul id="listQuiz"></ul>
<script src="option.js"></script>
</body>
</html>option.css
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
h2 {
margin: 2rem 0 0;
font-size: 16px;
text-align: center;
}
h3 {
margin: 1rem;
font-size: 16px;
text-align: center;
}
textarea {
display: block;
width: 100%;
padding: 1rem;
background: #f2f2f2;
border: none;
border-radius: 3px;
font-size: 12px;
}
/* input */
#choice1, #choice2, #choice3, #choice4, #answer {
display: block;
width: 100%;
margin: 1rem 0;
padding: 1rem;
background: #f2f2f2;
border: none;
border-radius: 3px;
font-size: 12px;
}
/* wrap */
.wrapForm {
width: 80%;
margin: 1rem auto;
}
.wrapAnswer {
display: flex;
justify-content: space-between;
margin: 1rem;
}
.wrapAnswer div {
margin-right: 2rem;
font-size: 16px;
}
.wrapAnswer span {
display: block;
font-size: 16px;
}
/* p */
.pQuestion {
display: block;
width: 100%;
margin: 1rem;
font-size: 12px;
}
.pChoice {
display: block;
width: 100%;
margin: 1rem;
font-size: 12px;
}
.pAnswer {
display: block;
width: 100%;
margin: 1rem;
font-size: 12px;
}
/* btn */
button {
cursor: pointer;
}
#btnAddQuiz {
display: block;
width: 100%;
padding: 1rem;
background: #806cd4;
border: none;
border-radius: 3px;
color: #fff;
}
.btnDel {
display: block;
width: 100%;
padding: 1rem;
background: #806cd4;
border: none;
border-radius: 3px;
color: #fff;
}
/* list */
ul,li {
list-style: none;
}
#listQuiz {
display: block;
width: 80%;
margin: 0 auto;
}
.itemQuiz strong {
font-size: 14px;
}
.itemQuiz span {
display: block;
width: 100%;
margin: 1rem 0;
padding: 1rem;
border: 1px solid #f2f2f2;
border-radius: 3px;
font-size: 14px;
}
option.js
async function getQuiz() {
const data = await chrome.storage.local.get(["quiz"]);
return data.quiz || [];
}
// ======================
// HTMLインジェクション対策
// ======================
function escapeHTML(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// ===========
// クイズの描画
// ===========
async function renderQuiz() {
const listQuiz = document.getElementById("listQuiz");
listQuiz.innerHTML = "";
const quizs = await getQuiz();
if (quizs.length === 0) {
listQuiz.innerHTML = "<p>クイズはまだ登録されていません</p>";
return;
}
quizs.forEach((quiz, index) => {
const li = document.createElement("li");
li.className = "itemQuiz";
li.innerHTML = `
<div>
<strong>Q${index + 1}. ${quiz.question}</strong>
${quiz.choices.map((choice, index) => `<span>${index + 1}: ${escapeHTML(choice)}</span>`).join("")}
<div class="wrapAnswer">
<div>正解</div>
<span>${quiz.answer + 1}</span>
</div>
</div>
<button class="btnDel" data-index="${index}">削除</button>
`;
listQuiz.appendChild(li);
});
}
// ===========
// クイズの削除
// ===========
document.querySelectorAll(".btnDel").forEach(btn => {
btn.addEventListener("click", async () => {
const index = parseInt(btn.dataset.index);
const quizs = await getQuiz();
quizs.splice(index, 1);
await chrome.storage.local.set({ quiz: quizs });
renderQuiz();
});
});
// ===========
// クイズの追加
// ===========
async function addQuiz() {
const q = document.getElementById("question").value.trim();
const c1 = document.getElementById("choice1").value.trim();
const c2 = document.getElementById("choice2").value.trim();
const c3 = document.getElementById("choice3").value.trim();
const c4 = document.getElementById("choice4").value.trim();
const a = parseInt(document.getElementById("answer").value);
//
if (!q || [c1, c2, c3, c4].some(c => !c) || isNaN(a)) {
alert("問題・選択肢・答えを正しく入力してください");
return;
}
// ストレージへデータを追加する
const data = await chrome.storage.local.get(["quiz"]);
const quizs = data.quiz || [];
quizs.push({ question: q, choices: [c1, c2, c3, c4], answer: a - 1 });
await chrome.storage.local.set({ quiz: quizs });
// フォームを空にする
alert("クイズを追加しました");
document.getElementById("question").value = "";
document.getElementById("choice1").value = "";
document.getElementById("choice2").value = "";
document.getElementById("choice3").value = "";
document.getElementById("choice4").value = "";
document.getElementById("answer").value = "";
//
renderQuiz();
}
document.getElementById("btnAddQuiz").addEventListener("click", addQuiz);
// 初期表示
renderQuiz();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"
},
"content_scripts": [ <--- 特定のURLにマッチしたときだけ実行するスクリプト
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["content.css"],
"run_at": "document_idle" <--- ページのロードが完了した時点でcontent.jsを実行
}
],
"icons": {
"128": "icon128.png"
}
}Jsonファイルにはコメントアウトを入れることができません
BACK