Cosenseのページ範囲を指定してクリップボードにコピーするUserScript
スクボ読書する際に、数ページにまたがる内容を一度に取得したい
要約したり、翻訳したりするために
最初は、UserScriptからOpenAI APIを呼んで要約するようにしていた
でも、別にそこまでしなくてもclipboardに入るだけで便利
プロンプトを自分で決められるなどなど
一部制約がある
スクボ読書の特定のフォーマットに沿っていないといけない
基本的には、連番でいいのだが
たまに章題をタイトルに含んでいることが有る
そのため、「次のページ」をプログラムで確定できない
なにか策が必要
リストを表示してチェックボックスにチェックする
正規表現などで、その数字から始まるものを対象に含める
内容を読んで、nextの次のタイトルを見る
形式が決まってしまうが、これが一番確実ではある
ここを後で改修可能なように実装しておくとか
いったんこれにした

わかりづらいが、モーダルが消えたタイミングでクリップボードに丸々内容が入ってる
ここだけフォーマット依存
script.js/**
* Parses the next page title from the page text.
*
* このようなページのフォーマットを前提にしている
* ```
* prev: [015]
* next: [017]
* ...
* ```
*
* @param {string} pageText - The text content of the current page.
* @returns {string|null} The next page title, or null if not found.
*/
function parseNextPageTitle(pageText) {
const nextLineMatch = pageText.match(/^next:\s*\[(.*?)\]/m);
if (nextLineMatch && nextLineMatch[1]) {
return nextLineMatch[1].replace(/\s/g, "_");
}
return null;
}
script.js// @ts-check
// TODO: もっと目立たないところに置く?
scrapbox.PageMenu.addMenu({
title: "ページ内容をコピー",
image: "https://gyazo.com/a1171e2083db9a394dc80072bbeb82da/raw",
onClick: async () => main(),
});
async function main() {
const { render, close, log, isShown } = modal();
// TODO: clean
const onSubmit = async (endPage) => {
const start = pageTitle();
const generator = pagesContentGenerator(start, endPage);
let result = "";
for await (const { title, content } of generator) {
if (!isShown()) {
log("Process aborted.");
break;
}
result += content;
log(title);
await sleep(200);
}
await navigator.clipboard.writeText(result);
console.log("Content copied to clipboard:", result);
close();
};
render(onSubmit);
}
function modal() {
const container = $("<div></div>");
let shown = false;
function render(onSubmit) {
shown = true;
const thisPage = pageTitle();
const formHtml = `
<div id="inputForm" style="position:fixed;top:20%;left:30%;background:white;padding:20px;box-shadow:0 0 10px rgba(0,0,0,0.5);display:grid;grid-template-rows:auto auto auto;gap:10px;z-index:10;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;">
<div style="font-size:2rem;">ページ範囲を入力</div>
<button id="closeButton" style="background:none;border:none;font-size:1.2em;cursor:pointer;">×</button>
</div>
<div style="">close or reload で中断</div>
<div>
<p>${thisPage} ~ <input id="endPage" placeholder="091" style="width:16rem;"></p>
</div>
<div style="display:flex;justify-content:space-between;">
<button id="submitButton">submit</button>
</div>
<div id="logContainer" style="color:gray;font-size:0.9em;"></div>
</div>
`;
container.html(formHtml);
$("body").append(container);
setupEventListeners(onSubmit);
}
function close() {
shown = false;
container.remove();
}
function isShown() {
return shown;
}
function setupEventListeners(onSubmit) {
$("#submitButton").on("click", () => {
const endPage = $("#endPage").val();
onSubmit(endPage);
});
$("#closeButton").on("click", close);
}
function log(message) {
const logContainer = $("#logContainer");
logContainer.append(`<div>${message}</div>`);
}
return { render, close, log, isShown };
}
/**
* ページ内容を逐次取得する
*
* @param {string} start - The starting page number.
* @param {string} end - The ending page number.
* @returns {AsyncGenerator<{title: string, content: string}>} The generator.
*/
async function* pagesContentGenerator(start, end) {
let currentTitle = start;
while (true) {
const pageText = await pageBody(currentTitle);
yield { title: currentTitle, content: pageText };
if (currentTitle === end) {
break;
}
const nextPageTitle = parseNextPageTitle(pageText);
if (nextPageTitle == null) {
break;
}
currentTitle = nextPageTitle;
}
}
/**
* @param {string} title
* @returns {Promise<string>} The body of the page.
*/
async function pageBody(title) {
const n = projectName();
const url = encodeURI(`https://scrapbox.io/api/pages/${n}/${title}/text`);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch page ${title}`);
}
return await res.text();
}
/**
* @returns {string} The encoded project name.
*/
function projectName() {
return scrapbox.Project.name;
}
/**
* @returns {string} The encoded page title.
*/
function pageTitle() {
return scrapbox.Page.title;
}
/**
* @param {number} ms
* @returns {Promise<void>}
*/
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}