Greasy Fork is available in English.

Web漫画アンテナお気に入り管理

d:作者名を読み込む a:作者をお気に入りに追加/削除 e:検索ワード入力 Shift+E:全編集

// ==UserScript==
// @name        Web漫画アンテナお気に入り管理
// @description d:作者名を読み込む a:作者をお気に入りに追加/削除 e:検索ワード入力 Shift+E:全編集
// @match       *://webcomics.jp/*
// @version     0.1.4
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_deleteValue
// @namespace   https://greasyfork.org/users/181558
// @require     https://code.jquery.com/jquery-3.4.1.min.js
// ==/UserScript==

(function() {
  var keyFunc = [];
  var INTERVAL = function() { return 5000; }
  const isBusy = function() { return Number(pref("busy") || 0) > Date.now() }
  const setBusy = function(delay = INTERVAL()) { if (Date.now() + delay > Number(pref("busy") || 0)) pref("busy", Date.now() + delay) }

  var scrollForGet = 0;
  const V = 0; // 1-3:verbose

  var db = {};
  db.manga = pref("db.manga") || []
  db.favo = pref('db.favo') || [];
  var latestget = Date.now() - INTERVAL()
  var busy = 0;
  var GF = {}
  document.querySelector(`head`).insertAdjacentHTML('beforeend', `<style>.waiting{ display: inline-block; vertical-align: middle; color: #666; line-height: 1; width: 1em; height: 1em; border: 0.12em solid currentColor; border-top-color: rgba(102, 102, 102, 0.3); border-radius: 50%; box-sizing: border-box; -webkit-animation: rotate 1s linear infinite; animation: rotate 1s linear infinite; } @-webkit-keyframes rotate { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } } @keyframes rotate { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }</style>`)

  String.prototype.autrep = function() { return this.replace(/\([^)]*\)|([^)]*)|原作|作画|漫画|キャラクター|ネーム|原案|著者|作者|シナリオ|[作|画][\::]|\:|:|・|\,|、|,|\/|/|\+|+|\&|&/gmi, " ").replace(/ +|\s+/gmi, " ").trim() } // gフラグ不可
  String.prototype.match0 = function(re) { let tmp = this.match(re); if (!tmp) { return null } else if (tmp.length > 1) { return tmp[1] } else return tmp[0] } // gフラグ不可
  String.prototype.sanit = function() { return this.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/`/g, '&#x60;') }

  function adja(place = document.body, pos, html) {
    return place ? (place.insertAdjacentHTML(pos, html), place) : null;
  }
  var JS = (v) => { return JSON.stringify(v) }
  var JP = (v) => { return JSON.parse(v) }

  var mousex = 0;
  var mousey = 0;
  var hovertimer
  document.addEventListener("mousemove", e => ((mousex = e.clientX), (mousey = e.clientY), (hovertimer = 0), undefined), false)

  var keyListen = function(e) {
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.getAttribute('contenteditable') === 'true') return;
    var key = (e.shiftKey ? "Shift+" : "") + (e.altKey ? "Alt+" : "") + (e.ctrlKey ? "Ctrl+" : "") + e.key;
    var ele = document.elementFromPoint(mousex, mousey);
    var sel = (window.getSelection) ? window.getSelection().toString().trim() : ""
    if (pushkey(key, ele, sel)) { e.preventDefault(); return false }
  }
  document.addEventListener('keydown', keyListen, false)

  document.addEventListener("mousedown", function(e) { // クリック
    var ele = document.elementFromPoint(mousex, mousey);
    if (e.button == 0 && ele.dataset.key) {
      if (pushkey(ele.dataset.key, ele)) return false
    }
  })
  document.addEventListener("contextmenu", function(e) { // クリック
    var ele = document.elementFromPoint(mousex, mousey);
    if (ele.dataset.keyr) {
      if (pushkey(ele.dataset.keyr, ele)) { e.preventDefault(); return false }
    }
  })

  function storemanga(tit, aut, ele) {
    db.manga = pref("db.manga") || []
    db.manga = db.manga.filter(v => v.t != tit)
    db.manga.push({ t: tit, a: aut })
    db.manga = (Array.from(new Set(db.manga.map(v => JSON.stringify(v))))).map(v => JSON.parse(v)) // uniq:オブジェクトの配列→JSON文字列配列→uniq→オブジェクトの配列
    pref("db.manga", db.manga)
    V >= 3 && console.table(db.manga)
    run(ele)
  }

  function addaut(aut, ele = document) {
    if (!aut || aut == "-") return
    aut = aut.autrep() // 加工後の作者名で記憶する
    db.favo = pref("db.favo") || []
    if (!db.favo.includes(aut)) { db.favo.push(aut) } else { db.favo = db.favo.filter(v => v !== aut) }
    db.favo = (Array.from(new Set(db.favo.map(v => JSON.stringify(v))))).map(v => JSON.parse(v)) // uniq:オブジェクトの配列→JSON文字列配列→uniq→オブジェクトの配列
    pref("db.favo", db.favo)
    V >= 3 && console.table(db.favo)
    run(ele)
  }

  var que = {
    q: [], //{ele,key}
    add: function(ele, key) {
      this.q.push({ ele: ele?.closest(".entry"), key: key })
    },
    do: function() {
      V >= 2 && this.q.length && console.table(this.q)
      this.q.forEach(v => {
        var box = v.ele
        var key = v.key
        if (!box) { v.stop = 1; return 0 }
        var tit = eleget0('div.entry-title>a:first-child', box)?.textContent?.trim()
        var desc = eleget0('//span[@class="entry-detail"]/a[1]', box)
        var aut = eleget0('.aut', box)?.dataset?.author;

        if (aut == "-") { v.stop = 1; return 0 }
        if (aut) aut = decodeURI(aut)
        var descurl = desc?.href
        if (aut) {
          storemanga(tit, aut, box.parentNode)
          key == "a" && addaut(aut, box.parentNode)
          autsearch()
          v.stop = 1;
          return 0
        }

        var q = eleget0('.autq:not(.waiting)', box);
        if (q) { q.classList.add("waiting"); }
        box.dataset.que = 1

        if (descurl && !aut && !box.dataset.wait && Date.now() - latestget > INTERVAL() && !busy && !isBusy()) {
          busy = 1;
          setBusy();
          box.dataset.wait = 1
          v.stop = 1

          var quee = eleget0('.autq', box)
          quee.style.color = "#f0f"

          V >= 1 && notify(`${"get:" + tit}\n${(Date.now() - latestget)/1000} sec.`, document.title)
          latestget = Date.now()

          if (scrollForGet) box.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" })
          $.get(descurl).done(got => {
            setBusy(INTERVAL() / 2)
            busy = 0
            delete box.dataset.que;
            aut = $('div.comic-info-right div.comic-author', got)?.text()?.replace("作者: ", "").trim() || "-"
            V > 1 && notify(`${"done:" + tit}\n${aut}`, document.title)

            if (aut) {
              storemanga(tit, aut, box.parentNode)
              if (key == "a") addaut(aut, box.parentNode)
            }
          }).fail(err => {
            alert(`通信エラー\n${descurl}`)
            this.q = []
            delete box.dataset.que;
            location.reload();
          })
          autsearch()
        }
      })
      this.q = this.q.filter(v => !v.stop)
    },
  }
  setInterval(() => { que.do() }, 1000)

  function pushkey(key, ele = null, sel = "") {
    keyFunc.forEach(v => { if (v.key === key) { v.func(ele) } })
    if (/^open:/.test(key)) {
      window.open(key.replace(/^open:/, ""))
      return 1
    }
    if (key === "e") { // e::
      db.manga = pref("db.manga") || []
      db.favo = pref('db.favo') || [];
      var favo = [...db.favo]

      GF.sorttype = ((GF.sorttype || 0) % 3 + 1)
      var [order, finstrfunc] = [
        ["登録順", a => a.join(" ")],
        ["abc順", a => a.sort(new Intl.Collator("ja", { numeric: true, sensitivity: 'base' }).compare).join(" ")],
        ["長さ→abc順", a => a.sort((a, b) => a.length == b.length ? (new Intl.Collator("ja", { numeric: true, sensitivity: 'base' }).compare)(a, b) : a.length > b.length ? 1 : -1).join(" ")]
      ][GF.sorttype - 1]
      var target = (window.getSelection() && window.getSelection().toString().trim()) || (prompt(`お気に入りに登録するキーワードを入力してください\nすでに登録されている文字列を入力するとそれを削除します\n\n現在登録済み(${favo.length}): (${["登録順","abc順","長さ→abc順"][GF.sorttype-1]})\n${finstrfunc([...favo])}\n\n`) || "")?.trim();
      target = target?.trim()
      if (!target) return;
      if (db.favo.includes(target)) {
        if (confirm(`『${target}』は既に存在します\n削除しますか?\n`)) {
          V && alert(`『${target}』をメモから削除しました`)
          db.favo = db.favo.filter(v => v != target)
        }
      } else {
        db.favo.push(target)
      }
      pref("db.favo", db.favo)
      pref("db.manga", db.manga)
      run()
    }
    if (key === "d" || key == "a") { // d:: a::
      let descele = eleget0(".comic-info .aut", ele?.closest("#main")) || ele;
      if (key == "a" && descele.dataset.author) { //alert("!");
        var aut = decodeURI(descele.dataset.author)
        addaut(aut)
        autsearch()
        return 1
      }
      que.add(ele, key);
      que.do()
      return 1
    }
    if (key === "Shift+E") { // E::
      var tmp = prompt(`作品情報(${db.manga.length}) / お気に入り作者(${db.favo.length})\n全設定値をJSON形式で編集してください\n空欄を入力すれば全削除できます\n先頭の{の前に+を付けると現在のデータに追加(マージ)します\n\n` + JS(db), JS(db))
      if (tmp !== null) { // ESCで抜けたのでなければ
        try {
          if (tmp?.trim()?.match(/^\+|^+/)) {
            tmp = tmp?.trim()?.replace(/^\+|^+/, "")?.trim()
            db.manga = (pref("db.manga") || []).concat(JSON.parse(tmp || "").manga)
            db.favo = (pref('db.favo') || []).concat(JSON.parse(tmp || "").favo)
            tmp = JSON.stringify(db)
          }
          var dbtmp = JP(tmp || '{"favo":[],"manga":[]}')
          dbtmp.manga = (Array.from(new Set(dbtmp.manga.map(v => JSON.stringify(v))))).map(v => JSON.parse(v)) // uniq:オブジェクトの配列→JSON文字列配列→uniq→オブジェクトの配列
          dbtmp.favo = [...new Set(dbtmp.favo)]; // uniq
          db = dbtmp
          pref("db.favo", db.favo || [])
          pref("db.manga", db.manga || [])
          run();
        } catch (e) {
          alert(e + "\n入力された文字列がうまくparseできなかったので設定を変更しません\n正しいJSON書式になっているか確認してください");
          return false
        }
      }
      return 1
    }
  }

  run()
  document.body.addEventListener('AutoPagerize_DOMNodeInserted', function(evt) { run(eleget0('.list.top', evt.target) || evt.target?.closest(".list.top") || document); }, false);

  // タブにフォーカスが戻ったら再実行
  window.addEventListener("focus", () => {
    db.manga = pref("db.manga") || []
    db.favo = pref('db.favo') || [];
    run()
  })

  // 詳細画面
  var aut = $('div.comic-info-right div.comic-author')?.text()?.replace("作者: ", "")?.trim() || "-"
  var tit = eleget0('//div/div/div/div[@class="comic-title"]/h2/a[1]')?.textContent?.trim()

  if (aut && tit) {
    storemanga(tit, aut, document)
  }

  function autsearch() {
    elegeta(".autsearchele").forEach(e => e.remove());
    let tmp = pref('db.favo') || [];
    if (tmp.length) {
      var aut = tmp[0]
      var u = `https://webcomics.jp/search?q=${encodeURI(aut.autrep())}`
      var u2 = `https://webcomics.jp/search?q=${(aut.autrep())}`
      var l = aut != "-" ? `data-keyr="open:${u}"` : ""
      var e = adja(eleget0('//div[@id="side"]'), "afterbegin", `<div class="autsearchele ignoreMe"><a id="auta" href="${u}" title='左クリック:このキーワードを検索\n右クリック:開かずにキーワードを変更' style="font-size:12px; ">${aut.autrep().sanit()}</a> を検索 <span title="左クリック/e:お気に入りワードを追加\n右クリック/Shift+E:全設定値を編集" style="cursor:pointer;" data-key="e" data-keyr="Shift+E">&#128458;</span></div>`)?.childNodes[0]
      elegeta('#auta,#changeaut').forEach(e => {
        e.addEventListener("click", v => {
          db.favo = pref('db.favo') || [];
          if (!db.favo.length) return
          db.favo.push(db.favo.shift())
          pref('db.favo', db.favo)
          autsearch()
        })
        e.addEventListener("mouseup", v => {
          if (v.button == 0) return
          setTimeout(() => {
            db.favo = pref('db.favo') || [];
            if (!db.favo.length) return
            db.favo.push(db.favo.shift())
            pref('db.favo', db.favo)
            autsearch()
          }, 17)
          if (v.button != 1) { v.preventDefault(); return false; }
        })
        e.addEventListener("contextmenu", v => { v.preventDefault(); return false })
      })
    } else {
      var e = adja(eleget0('//div[@id="side"]'), "afterbegin", `<div class="autsearchele ignoreMe"><span title="左クリック/e:お気に入りワードを新規作成\n右クリック/Shift+E:全設定値を編集" style="cursor:pointer;" data-key="e" data-keyr="Shift+E">&#128458;</span></div>`)?.childNodes[0]
    }
  }

  function run(node = document) { // run::
    autsearch()
    elegeta('.autele', node).forEach(v => v.remove())

    // 一覧画面
    elegeta('.entry', node).forEach(v => {
      var title = eleget0('.entry-title>a:first-child', v)?.textContent?.trim()
      var aut = db.manga.find(v => v.t === title)?.a
      if (aut == "-") {
        adja(eleget0('.entry-date', v), "beforeend", `<span data-author="${encodeURI(aut)}" class="autele aut" style="cursor:pointer;font-size:12px; margin:0 0 0 1em; color:#444;">${aut.sanit()}</span>`)
      } else if (aut) {
        var memo = db.favo.includes(aut.autrep()) // 加工後の作者名で記憶する
        var u = `https://webcomics.jp/search?q=${encodeURI(aut.autrep())}`
        var l = aut != "-" ? `data-keyr="open:${u}"` : ""
        adja(eleget0('.entry-date', v), "beforeend", `<span data-author="${encodeURI(aut)}" ${memo?'data-gakusai="1"':''} class="autele aut" title='${aut}\nクリック/a:作者をお気に入りに追加/解除' data-key="a" style=" cursor:pointer; ${memo?"color:#00f;":"color:#444;"}font-size:12px; margin:0 0 0 1em;">${memo?"●":"○"}</span><a href="${u}" data-author="${encodeURI(aut)}" class="autele aut autname" title='${aut.sanit()}\n左クリック:作者を検索\na:作者をお気に入りに追加/解除' style=" ${memo?"color:#00f;font-weight:bold;":"color:#444;"}font-size:12px; margin:0 0 0 0.25em;">${aut.autrep().sanit()}</a>`)
      } else {
        adja(eleget0('.entry-date', v), "beforeend", `<span data-key="d" title="d:作者を取得\na:作者をお気に入りに追加" class="autele autq${v.dataset.que?" waiting":""}" style="cursor:pointer;font-size:12px; margin:0 0 0 1em; ${memo?"color:#00f;font-weight:bold;":"color:#444;"}">?</span>`)
      }
    })

    // 詳細画面
    var aut = $('div.comic-info-right div.comic-author')?.text()?.replace("作者: ", "")?.trim() || "-"
    var tit = eleget0('//div/div/div/div[@class="comic-title"]/h2/a[1]')?.textContent?.trim()
    if (aut && tit) {
      if (aut == "-") {
        adja(eleget0('.comic-info'), "afterbegin", `<span data-author="${encodeURI(aut)}" class="autele aut" style="float:right; font-size:12px; margin:0 0 0 1em; color:#444;">${aut.sanit()}</span>`)
      } else if (aut) {
        var memo = db.favo.includes(aut.autrep()) // 加工後の作者名で記憶する
        var u = `https://webcomics.jp/search?q=${encodeURI(aut.autrep())}`
        var l = aut != "-" ? `data-keyr="open:${u}"` : ""
        V && notify(aut, memo)
        adja(eleget0('.comic-info'), "afterbegin", `<span class="autele" style="float:right; "><span data-author="${encodeURI(aut)}" class="aut" title='${aut}\nクリック/a:作者をお気に入りに追加/解除' data-key="a" style=" cursor:pointer; ${memo?"color:#00f;":"color:#444;"}font-size:12px; margin:0 0 0 1em;">${memo?"●":"○"}</span><a href="${u}" data-author="${encodeURI(aut)}" class="autele aut autname" title='${aut}\n左クリック:作者を検索\na:作者をお気に入りに追加/解除' style=" ${memo?"color:#00f;font-weight:bold;":"color:#444;"}font-size:12px; margin:0 0 0 0.25em;">${aut.autrep().sanit()}</a></span>`)
      }
    }

  }

  function elegeta(xpath, node = document) {
    if (!xpath || !node) return [];
    let flag
    if (!/^\.?\//.test(xpath)) return /:inscreen$/.test(xpath) ? [...node.querySelectorAll(xpath.replace(/:inscreen$/, ""))].filter(e => { var eler = e.getBoundingClientRect(); return (eler.top > 0 && eler.left > 0 && eler.left < document.documentElement.clientWidth && eler.top < document.documentElement.clientHeight) }) : /:visible$/.test(xpath) ? [...node.querySelectorAll(xpath.replace(/:visible$/, ""))].filter(e => e.offsetHeight) : [...node.querySelectorAll(xpath)]
    try {
      var array = [];
      var ele = document.evaluate("." + xpath.replace(/:visible$/, ""), node, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
      let l = ele.snapshotLength;
      for (var i = 0; i < l; i++) array[i] = ele.snapshotItem(i);
      return /:visible$/.test(xpath) ? array.filter(e => e.offsetHeight) : array;
    } catch (e) { alert(e + "\n" + xpath + "\n" + JSON.stringify(node)); return []; }
  }

  function eleget0(xpath, node = document) {
    if (!xpath || !node) return null;
    if (!/^\.?\//.test(xpath)) return /:inscreen$/.test(xpath) ? [...node.querySelectorAll(xpath.replace(/:inscreen$/, ""))].filter(e => { var eler = e.getBoundingClientRect(); return (eler.top > 0 && eler.left > 0 && eler.left < document.documentElement.clientWidth && eler.top < document.documentElement.clientHeight) })[0] ?? null : /:visible$/.test(xpath) ? [...node.querySelectorAll(xpath.replace(/:visible$/, ""))].filter(e => e.offsetHeight)[0] ?? null : node.querySelector(xpath.replace(/:visible$/, ""));
    try {
      var ele = document.evaluate("." + xpath.replace(/:visible$/, ""), node, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
      return ele.snapshotLength > 0 ? ele.snapshotItem(0) : null;
    } catch (e) { alert(e + "\n" + xpath + "\n" + JSON.stringify(node)); return null; }
  }

  function pref(name, store = null) { // prefs(name,data)で書き込み(数値でも文字列でも配列でもオブジェクトでも可)、prefs(name)で読み出し
    if (store === null) { // 読み出し
      let data = GM_getValue(name) || GM_getValue(name);
      if (data == undefined) return null; // 値がない
      if (data.substring(0, 1) === "[" && data.substring(data.length - 1) === "]") { // 配列なのでJSONで返す
        try { return JSON.parse(data || '[]'); } catch (e) {
          alert("データベースがバグってるのでクリアします\n" + e);
          pref(name, []);
          return;
        }
      } else return data;
    }
    if (store === "" || store === []) { // 書き込み、削除
      GM_deleteValue(name);
      return;
    } else if (typeof store === "string") { // 書き込み、文字列
      GM_setValue(name, store);
      return store;
    } else { // 書き込み、配列
      try { GM_setValue(name, JSON.stringify(store)); } catch (e) {
        alert("データベースがバグってるのでクリアします\n" + e);
        pref(name, "");
      }
      return store;
    }
  }

  function notify(body, title = "") {
    if (!("Notification" in window)) return;
    else if (Notification.permission == "granted") new Notification(title, { body: body });
    else if (Notification.permission !== "denied") Notification.requestPermission().then(function(permission) {
      if (permission === "granted") new Notification(title, { body: body });
    });
  }

})()