// ==UserScript==
// @name 5ch 画像&動画etc
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description 画像&YouTube動画読込表示・画像スレ抽出・板リンク抽出・jump(リンク中継)無効・シンプルな見た目
// @author 匿名Cat
// @match https://*.5ch.net/test/read.cgi/*/*
// @match http://*.5ch.net/test/read.cgi/*/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/arrive/2.4.1/arrive.min.js
// @resource bootstrap.min.css https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css
// @icon https://www.google.com/s2/favicons?domain=5ch.net
// @grant GM_download
// @grant GM_addStyle
// @grant GM_getResourceText
// @license MIT
// ==/UserScript==
(function() {
'use strict';
$?.noConflict()
const extract5chURL = hrefStr => hrefStr?.match(/(?:http:\/\/jump\.5ch\.net\/\?)(.+)/)?.[1]
const optionalHttps = hrefStr => /^https?:\/\//.test(hrefStr) ? hrefStr : 'https://' + hrefStr
// 設定開始================
'jpg png webp jpeg gif'
const settings = {
imgExts: 'jpg png webp jpeg gif'.split(' '),
keys: { download: ['d'], removePreview: ['c', 'Escape']}
}
// 直接生成型画像 サイズ
const size = '10rem'
const forHost = {
0: {
matcher: /https?:\/\/(?!hebi|leia).+\.5ch\.net\/test\/read.cgi\//,
getSrc: extract5chURL,
append: '.message',
},
"hebi.5ch.net": {
getSrc: href => href,
append: 'dd'
},
"leia.5ch.net": {
getSrc: extract5chURL,
append: 'dd'
}
}
const userScriptId = "ch_im_and_video__"
// 設定終了==============
// bootstrap style読み込み
GM_addStyle(GM_getResourceText("bootstrap.min.css"))
const style = `
html { font-size: 1rem; }
/* 画像プレビュー周り */
.container.container_body { width: 100%; max-width: 100%; margin: 0; padding: 0; display: flex; position: relative; }
.container.container_body>.contents { width: 60vw; padding-left: 4vw; height: 100vh; overflow: auto; resize: horizontal; }
.preview-container { flex: 1; position: relative; }
.preview-container:before { content: ""; display: block; padding-top: 75%; }
.preview-container>img { position: absolute; top: 0; left: 0; bottom: 0; right: 0; width: 100%; max-height: 100vh; object-fit: contain; z-index: 3;}
a.image { font-size: 0; }
/* ナビゲーションバー */
#${userScriptId} { position: sticky; top: 0; background-color: #fff; }
#${userScriptId} .title { margin-top: 0; padding-top: 20px; }
#${userScriptId}extract_im { cursor: pointer; }
#${userScriptId} .related-thread { display: inline-block; }
#${userScriptId}relations { font-size: inherit; }
/* 5ch公式のGUI */
.topmenu.centered,.bottommenu.centered,.input-group { display: none; }
.submitbtn.btn { font-size: inherit; }
.rBtn { border: none; }
.post_hover { z-index: 3; }
.menujust { margin:0; padding-left: 0; }
/* お絵かき */
.wPaint-canvas { box-shadow: 0 0 2rem rgba(0, 0, 0, .2); }
`
// util
const d = document
const isImageUrl = url => new RegExp(`\\.${settings.imgExts.join('|')}$`, 'g').test(url)
const utilUnion = arr => [...new Set(arr)]
jQuery(d).ready($ => {
const $body = $(d.body)
// スタイルをあてる
$body.append($('<style>').addClass(userScriptId).text(style))
const urlObj = new URL(location.href)
const [,{getSrc}] = Object.entries(forHost).find(([host, {matcher}]) => host.split(',').includes(urlObj.host) || matcher?.test(location.href))
const imageExtSelectors = settings.imgExts.map(ext => `a[href$=".${ext}"]`).join(',')
const $links = $('.escaped a,dd a')
const $imageLinks = $(`a.image,${imageExtSelectors}`)
const imageStyle = {maxHeight: size, maxWidth: size}
const posts = $('.post').get()
// ボード名など抽出
const urlMtc = location.href.match(/https?:\/\/(.+\.5ch\.net)\/test\/read.cgi\/(\w+)\/.*/)
const boardName = urlMtc?.[2]
const boardTopHref = urlMtc ? `//${urlMtc[1]}/${urlMtc[2]}` : ''
// 以下、独自ナビバー関連機能
$('.container.container_body').arrive('.contents', contents => {
const $nav = $('<nav>', {id: `${userScriptId}`})
const $contents = $(contents)
$contents.prepend($nav)
$nav.html(`
<a href="${boardTopHref}">板Top</a>
<a href="/${boardName}/subback.html">スレッド一覧<a>
<a id="${userScriptId}extract_im" class="link">画像スレ抽出</a>
<div class="related-thread">
<a class="dropdown-toggle" href="#" id="dropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">関連スレ</a>
<ul id="${userScriptId}relations" class="dropdown-menu" aria-labelledby="dropdownMenuLink"></ul>
</div>
`)
$nav.prepend($contents.children('.title'))
// 公式ナビバーを削除
$('nav.search-header').remove()
// 画像スレだけ抽出機能
posts.forEach(post => /* 改行削除、正規化 */$(post).css({display: 'block'}).next('br').remove())
const $notImgPosts = $(posts.filter(post => {
const $post = $(post)
return !$post.find('.image')[0] && !$post.find('a').get().some(a => isImageUrl($(a).attr('href')))
}))
let flag = false
const showImgPost = bool => $notImgPosts.css({display: bool ? 'block' : 'none'})
$(`#${userScriptId}extract_im`).click(() => {
showImgPost(flag)
flag = !flag
})
// 関連スレURL候補 抽出
const regMain = /(https:.+?)\/(\d+)(\/[\d-]+)?/
const mainLocationHref = location.href.match(regMain)?.[1]
if (mainLocationHref) {
const relationHrefs = posts.flatMap(post => {
return $(post).find('.escaped').find('a').get().map(a => {
const href = a.getAttribute('href')
const idx = href.indexOf(mainLocationHref)
return idx < 0 ? undefined : href
}).filter(Boolean)
})
utilUnion(relationHrefs).forEach(relationHref => {
let url
try { url = new URL(relationHref) } catch (e) { console.warn(e) }
const txt = relationHref.match(regMain)?.[2] ?? url.pathname ?? relationHref
const $li = $('<li>').addClass('dropdown-item')
const a = $('<a>').attr({ href: relationHref, target: '_blank' }).text(txt)
$li.append(a).appendTo(`#${userScriptId}relations`)
})
}
})// 以上、独自ナビバー関連機能
// 書き込みボタンを強調表示
$(".submitbtn.btn").addClass('btn btn-primary')
// プレビュー画面を用意
const $thread = $('.container.container_body')
$thread.wrapInner($('<div>').addClass('contents'))
const $previewContainer = $('<div>').addClass('preview-container')
$thread.append($previewContainer)
// プレビュー関数
const addPreview = imgUrl => $previewContainer.append($('<img>', {src: imgUrl}))
const removePreview = () => $previewContainer.empty()
$(d).on('keydown', e => { if (settings.keys.removePreview.includes(e.key)) removePreview() })
// 描画時
$links.get().forEach(link => {
const $link = $(link)
const href = getSrc($link.attr('href'))
if (typeof href !== 'string') return
const patterns = [/^.+:\/\/(?:www|m)\.youtube\.com.*?v=([\w-=]+).*$/, /^.+:\/\/youtu\.be\/([\w-=]+).*$/, /^.+:\/\/www\.youtube\.com\/embed\/([\w-=]+).*$/]
let match
for (const pattern of patterns) {
const mt = href.match(pattern)
if (mt) { match = mt; break }
}
if (!match) {
// 5ch外部サイト中継ページ無効化
if (!isImageUrl(href)) $link.attr('href', href)
return
}
// 以降 YouTube iframe 生成
$link.text('')
$link.after(
$('<iframe>', {src: `https://www.youtube.com/embed/${match?.[1]}?controls=1`})
.attr({frameborder: 0, allowfullscreen: ''})
.css({width: '40rem', height: '22.5rem'})
)
})
// 小サイズ画像の見た目の処理
$imageLinks.get().forEach(imageLink => {
const $imageLink = $(imageLink)
setTimeout(() => {
if ($imageLink.children('[div="thumb5ch"]')[0]) return
// 小サイズ画像が生成されてなかったら
const imgUrl = optionalHttps(getSrc($imageLink.attr('href')))
if (!imgUrl) return
const $addImg = $('<img>', {src: imgUrl, decoding: 'async'}).css(imageStyle)
$imageLink.after($addImg)
$addImg.on('mouseover', e => {
removePreview()
addPreview(imgUrl)
})
}, 2000)
$imageLink.children('div').css({display: 'inline', width: 'initial'})
// 改行を削除
$imageLink.next('br').remove()
})
// マウスホバーしたら画像をプレビュー
let previewImgUrl
$imageLinks.on('click', e => e.preventDefault())
$imageLinks.on('mouseover', e => {
removePreview()
const $target = $(e.currentTarget)
const imgUrl = optionalHttps(getSrc($target.attr('href')))
if (!imgUrl) return
previewImgUrl = imgUrl
addPreview(imgUrl)
})
// スペースキー押下でプレビュー画像ダウンロード
$(d).on('keydown', e => {
if (!settings.keys.download.includes(e.key) || !previewImgUrl) return
GM_download({url: previewImgUrl, name: previewImgUrl.replace(/^.+\//, ''), onerror: console.warn})
})
})
})();