browndust2.com news viewer

custom news viewer for sucking browndust2.com

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        browndust2.com news viewer
// @namespace   Violentmonkey Scripts
// @match       https://www.browndust2.com/robots.txt
// @grant       none
// @version     1.6.2
// @author      Rplus
// @description custom news viewer for sucking browndust2.com
// @require     https://unpkg.com/[email protected]/dist/localforage.min.js#sha384-MTDrIlFOzEqpmOxY6UIA/1Zkh0a64UlmJ6R0UrZXqXCPx99siPGi8EmtQjIeCcTH
// @require     https://unpkg.com/[email protected]/lib/marked.umd.js#sha256-XF49G0iwZLps3m+Jg99uZv4DSzMQYF1NxTXcT0XXjmU=
// @require     https://unpkg.com/[email protected]/dist/purify.min.js#sha256-WGpUzZ0j5KVvf7ufGyKpoVoWxa4B5dpoMFYoZh2+ndk=
// @@run-at     document-end
// @license     WTFPL
// ==/UserScript==

document.head.insertAdjacentHTML(
	'beforeend',
	`<link rel="icon" type="image/png" sizes="16x16" href="/img/seo/favicon.png">`
);

document.body.innerHTML = `
<form id="filterform">
	Filter
	<input type="search" name="q" tabindex="1" id="searchinput">
	<style id="filter_style"></style>
</form>

<div class="list" id="list" data-query=""></div>
<hr>
<input type="reset" value="Delete all cached data" id="delete_btn">

<select id="lang_select"></select>

<label class="showall-label">
	<input type="checkbox" class="showall" >
	show all list
</label>

<style>
*, *::before, *::after {
	box-sizing: border-box;
}
body {
	max-width: 1200px;
	margin: 0 auto;
	background-color: #e5cc9c;
	color: #111;
}

img {
	max-width: 100%;
}

h2 {
	display: inline;
	font-size: inherit;
	margin: 0;

	& span {
		font-weight: 400;
		font-size: smaller;
		vertical-align: middle;
		opacity: .5;
	}
}
@media (max-width:750px) {
	details summary {
		text-indent: -1.1em;
		padding-left: 1.5rem;
		padding: .8em .5em .5em 1.5em;

		&::marker {
			_font-size: smaller;
		}
	}
	h2 {
		position: relative;
	}
	h2 span {
		position: absolute;
		left: 1.2rem;
		bottom: 100%;
		font-size: 11px;
		opacity: .4;
	}
}

.ctx {
	background-color: #fff9;
	padding: 1em;

	p {
		white-space: pre-wrap;
	}

	& [style*="background-color"],
	& [style*="font-size"],
	& [style*="font-family"] {
		font-size: inherit !important;
		font-family: inherit !important;
		background-color: unset !important;
	}
}

.list {
	list-style: none;
	margin: 2em 0;
	padding-left: 50px;
}

summary {
	position: relative;
	top: 0;
	background-color: #dfb991;
	min-height: 50px;
	cursor: pointer;
	padding: .5em;
	place-content: center;

	&::before {
		content: '';
		position: absolute;
		inset: 0;
		background-color: #fff1;
		pointer-events: none;
		opacity: 0;
		transition: opacity .1s;
	}

	:target & {
		box-shadow: inset 0 -.5em #0003;
	}

	&:hover::before {
		opacity: 1;
	}

	& > img {
		position: absolute;
		top: 0;
		right: 100%;
		width: 50px;
		height: 50px;
	}
}

summary a {
	color: inherit;
	text-decoration: none;
	pointer-events: none;

	&:visited {
		color: #633;
	}
}

details {
	margin-block-start: 1em;

	&[open] summary {
		position: sticky;
		background-color: #ceac71;
		box-shadow: inset 0 -.5em #0003;
	}
}

#filterform {
	position: fixed;
	top: 0;
	left: 0;
	transition: opacity .2s;
	opacity: .1;

	&:hover,
	&:focus,
	&:focus-within {
		opacity: 1;
	}
}

body:not(:has(.showall:checked))
	.list[data-query=""]
		details:nth-child(n + 20) {
	display: none;
}

.showall-label {
	position: sticky;
	bottom: 0;
	display: block;
	width: fit-content;
	margin: 0 1em 0 auto;
	padding: .25em 1em .25em .5em;
	background-color: #0002;
	border-radius: 1em 1em 0 0;
	cursor: pointer;
}
</style>
`;

let data = [];
let news_map = new Map();
let query_arr = [];
let id_arr = [];
const lang_map = {
	'en-us': {
		full: 'en-us',
		fn: 'en',
	},
	'zh-tw': {
		full: 'zh-tw',
		fn: 'tw',
	},
	'zh-cn': {
		full: 'zh-cn',
		fn: 'cn',
	},
	'ja-jp': {
		full: 'ja-jp',
		fn: 'jp',
	},
	'ko-kr': {
		full: 'ko-kr',
		fn: 'kr',
	},
};
let lang = get_lang();

function render(id) {
	list.innerHTML = data.map(i => {
		let info = i.attributes;
		// let ctx = info.NewContent || info.content;
		let time = format_time(info.publishedAt);
		return `
			<details name="item" data-id="${i.id}" id="news-${i.id}">
				<summary>
					<img src="https://www.browndust2.com/img/newsDetail/tag-${info.tag}.png" width="36" height="36" alt="${info.tag}" title="#${info.tag}">
					<h2>
						<span>
							#${i.id} -
							<time datetime="${info.publishedAt}" title="${info.publishedAt}">${time}</time>
						</span>
						<a href="?id=${i.id}#news-${i.id}" tabindex="-1">${info.subject}</a>
					</h2>
				</summary>
				<article class="ctx"></article>
			</details>
		`;
	}).join('');

	list.querySelectorAll('details').forEach(d => {
		d.addEventListener('toggle', show);
	});

	list.addEventListener('click', (e) => {
		if (e.target.tagName === 'A' && (e.target.tabIndex === -1)) {
			e.preventDefault();
			console.log(123, e.target, e.target.href);
			history.pushState('', null, e.target.href);
		}
	});

	if (id) {
		auto_show(id);
	}
}

function auto_show(id) {
	let target = list.querySelector(`details[data-id="${id}"]`);
	if (target) {
		target.open = true;
		show({ target, });
	}
}

function show({ target, }) {
	if (!target.open) {
		target.scrollIntoView({behavior:'smooth', block: 'nearest'});
		return;
	}

	let id = +target.dataset.id;
	let ctx = target.querySelector(':scope > article.ctx');


	// target.scrollIntoView({behavior:'smooth', block: 'nearest'});
	let info = news_map.get(id)?.attributes;
	location.hash = `news-${id}`;
	history.pushState(`news-${id}`, null, `?id=${id}#news-${id}`);
	document.title = `#${id} - ${info.subject}`;

	if (!ctx || ctx.dataset?.init === '1' || !id) {
		return;
	}
	ctx.dataset.init = '1';

	let ori_link = `<a href="https://www.browndust2.com/${lang.full}/news/view?id=${id}" target="_bd2news" title="official link">#</a>`;
	if (!info) {
		ctx.innerHTML = ori_link;
		return;
	}

	let content = (info.NewContent || info.content);
	content = content.replace(/\<img\s/g, '<img loading="lazy" ');
	ctx.innerHTML = DOMPurify.sanitize(marked.parse(content + ori_link));
}

function format_time(time) {
	let _time = time ? new Date(time) : new Date();
	return _time.toLocaleString('zh-TW', {
		weekday: 'narrow',
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
	});
}

function query_kwd() {
	// console.time('query');
	let value = searchinput.value?.trim()?.toLowerCase();
	// console.log('query', value);
	if (!value) {
		filter_style.textContent = '';
		list.dataset.query = '';
		return;
	}

	let matched_ids = query_arr.map((i, index) => {
		let regex = new RegExp(value);
		return regex.test(i) ? id_arr[index] : null;
	})
	.filter(Boolean);

	if (matched_ids.length) {
		list.dataset.query = value;
	} else {
		list.dataset.query = '';
	}

	let selectors = matched_ids.map(i => `details[data-id="${i}"]`).join();
	filter_style.textContent = `
		details {display:none;}
		${selectors} { display: block; }
	`;
	// console.timeEnd('query');
}

// https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore?tab=readme-ov-file#_debounce
function debounce(func, wait, immediate) {
	var timeout;
	return function() {
		var context = this, args = arguments;
		clearTimeout(timeout);
		if (immediate && !timeout) func.apply(context, args);
		timeout = setTimeout(function() {
			timeout = null;
			if (!immediate) func.apply(context, args);
		}, wait);
	};
}

function get_lang() {
	let matched_langs = [
		new URL(location.href)?.searchParams?.get('lang') || '',
		localStorage.getItem('lang') || '',
		navigator.language.toLowerCase() || '',
		...(navigator.languages?.map(s => s.toLowerCase()) || [])
	]
	.filter(i => lang_map[i]);
	return lang_map[matched_langs[0]] || lang_map['zh-tw'];
}

let data_url = `https://www.browndust2.com/api/newsData_${lang.fn}.json?${+new Date()}`;
if (window.test_data_url) {
	data_url = window.test_data_url;
}

async function get_data() {
	try {
		let cached_etag = await localforage.getItem(`etag-${lang.fn}`) || '';
		let response = await fetch(data_url, {
			method: 'GET',
			cache: 'no-store',
			headers: {
				'If-None-Match': cached_etag,
			}
		});
		let new_etag = response.headers.get('etag');

		console.log(response);
		console.log({cached_etag, new_etag});

		if (response.status === 304) { // cached
			return await localforage.getItem(`data-${lang.fn}`);
		} else if (!response.ok) {
			throw new Error('fetch error', response);
		}

		let json = await response.json();
		let _data = json.data.reverse();
		localforage.setItem(`etag-${lang.fn}`, new_etag);
		localforage.setItem(`data-${lang.fn}`, _data);
		return _data;
	} catch(e) {
		throw new Error(e);
	}
}

async function init() {
	let qs_lang = new URL(location.href)?.searchParams?.get('lang') || '';
	if (qs_lang) {
		localStorage.setItem('lang', qs_lang);
	}
	lang_select.innerHTML = Object.values(lang_map).map(i => `<option value="${i.full}" ${i.full === lang.full ? 'selected' : ''}>${i.full}</option>`).join('');

	list.innerHTML = 'loading...';
	data = await get_data();
	console.log({data});
	data.forEach(i => {
		let info = i.attributes;
		let string = [
			i.id,
			info.content,
			info.NewContent,
			`#${info.tag}`,
			info.subject,
		].join().toLowerCase();

		id_arr.push(i.id);
		news_map.set(i.id, i);
		query_arr.push(string);
	});

	let id = new URL(location.href)?.searchParams?.get('id') || data[data.length - 1].id;
	render(id);

}

init();

lang_select.addEventListener('change', e => {
	let url = new URL(location.href);
	url.searchParams.set('lang', e.target.value)
	location.search = url.search;
});
filterform.addEventListener('submit', e => e.preventDefault());
searchinput.addEventListener('input', debounce(query_kwd, 300));
delete_btn.addEventListener('click', () => {
	localforage.clear();
	location.reload();
});