Amino Chat Grabber

A utility to grab and compile chat histories, for parsing, archiving or viewing in an accompanying WIP chat history viewer.

目前為 2022-11-05 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Amino Chat Grabber
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  A utility to grab and compile chat histories, for parsing, archiving or viewing in an accompanying WIP chat history viewer.
// @author       Rasutei
// @match        https://aminoapps.com/*
// @exclude		  https://aminoapps.com/partial/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=aminoapps.com
// @grant        none
// @license      GNU GPLv3
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==
/* eslint-disable curly, no-loop-func, no-return-assign */

//# Script version
const ScriptVersion = '2.0'

//# Settings
//- How long to wait for chat window pages to load when scrolling to the beginning of a chat (in ms).
//- Increase this if your connection is slow, and/or if you find that the script stops scrolling
//- prematurely. Very low values are unadvised, as you will be rate-limited by the server upon making
//- long operations, such as grabbing all chats in a community, or all chats in all communities.
//- Default value is 1500 (1.5 seconds)
const LoadDelay = 1500
//- Whether the script will save images as URIs directly into the file by default. Depending on how
//- many chats and how many sticker and image messages will be collected, it may incur a significant
//- filesize increase. Also, each compiled JSON will have its own setting for this.
//- Default is true.
const SaveURIs = true

//# CORS proxy for file requests
const corsp = 'https://api.codetabs.com/v1/proxy/?quest='

//# Classes
class CompObj {
	init(obj={}) {
		Array.from(Object.keys(obj)).forEach(key => this[key] = obj[key])
	}
	ErrURI(path, detail) {
		if (path == '') {
			console.warn('Error converting to URI: '+detail)
			return
		}
		console.group('Error converting to URI: '+detail)
		console.warn('Content path:\n'+path)
		console.groupEnd()
	}
}
class Community extends CompObj {
	name = ''
	notes = ''
	link = ''
	chats = []
	icon = ''
	iconURI = ''

	/**
	 * @param {{
			name:string,
			link:url,
			icon:url
		}} init
	 */
	constructor(init) {
		super()
		this.init(init)
	}

	genURIs(path='') {
		if (this.iconURI == '')
			URL2URI(this.icon, 'image/jpg', (uri) => this.iconURI = uri, () => this.ErrURI(path, 'Community icon'))
	}
}
class Chat extends CompObj {
	username = ''
	notes = ''
	link = ''
	oldest_timestamp = ''
	avatar = ''
	cover = ''
	avatarURI = ''
	coverURI = ''
	history = []

	/**
	 * @param {{
			username:string,
			link:url,
			avatar:url,
			cover:url
		}} init
	 */
	constructor(init) {
		super()
		this.init(init)
	}

	genURIs(path='') {
		if (this.avatarURI == '')
			URL2URI(this.avatar, 'image/jpg', (uri) => this.avatarURI = uri, () => this.ErrURI(path, 'User avatar'))
		if (this.coverURI == '')
			URL2URI(this.cover, 'image/jpg', (uri) => this.coverURI = uri, () => this.ErrURI(path, 'User cover'))
	}
}
class Message extends CompObj {
	type = ''
	user = ''
	bookmark = ''
	content = ''
	contentURI = ''

	/**
	 * @param {{
			type:string,
			user:string,
			content:string/url
		}} init
	 */
	constructor(init) {
		super()
		this.init(init)
	}

	genURIs(path='') {
		if (this.contentURI == '') {
			if (this.type == 'image' || this.type == 'sticker')
				URL2URI(this.content, 'image/jpg', (uri) => this.contentURI = uri, () => this.ErrURI(path, 'Message content of type '+this.type))
			if (this.type == 'audio')
				URL2URI(this.content, 'audio/aac', (uri) => this.contentURI = uri, () => this.ErrURI(path, 'Message content of type '+this.type))
		}
	}
}

//# File handling
const reader = new FileReader()
var json_internal = {
	gen_version: '2.0',
	cur_version: '2.0',
	save_uris: SaveURIs,
	communities: []
}
reader.onload = event => {
	json_internal = JSON.parse(event.target.result)
	window.chatcomp = json_internal
	MigrateFile()
	UpdateInfo()
}
reader.onerror = error => {
	log(error)
	Notify('Unable to read JSON file. Make sure you selected the right file, and that it is a valid JSON structure.\nRead the console for more info.')
}

//# UI styling
const css = $('<style></style>')
	.text(`
		#cgrabber {
			position: fixed;
			top: 0;
			left: 0;
			display: grid;
			padding: .3rem;
			text-align: center;
			background-color: rgb(10 10 10 / 50%);
			border: 1px solid rgb(40 40 40);
			border-left: 0;
			border-top: 0;
			border-bottom-right-radius: 1rem;
			z-index: 10000;
		}
		#cgrabber > * {
			box-sizing: border-box;
			display: flex;
			justify-content: center;
			align-items: center;
			padding: .3rem .5rem;
			margin: 2px;
			border: 1px outset rgb(40 40 40);
			border-radius: .3rem;
			background-color: rgb(20 20 20);
			color: white;
			font-family: 'Calibri', monospace;
			font-size: 1rem;
			transition: border-radius 300ms 100ms, border-color 300ms 0ms, background-color 300ms 0ms, color 300ms 0ms;
		}
		#cgrabber > :not(:first-child, dialog) {
			width: 100% !important;
		}
		#cgrabber > span {
			background-color: transparent;
			border: none;
		}
		#cgrabber > label:hover,
		#cgrabber > button:hover {
			background-color: rgb(50 50 50);
		}
		#cgrabber > :first-child {
			border-bottom-right-radius: 1rem;
		}
		#cgrabber:hover > :first-child,
		#cgrabber:focus-within > :first-child {
			border-radius: .3rem;
			transition-duration: 300ms;
			transition-delay: 200ms, 0ms;
		}
		#cgrabber > :last-child {
			border-bottom-right-radius: 1rem;
		}
		#cgrabber button,
		#cgrabber label {
			appearance: none;
			-webkit-appearance: none;
			cursor: pointer;
		}
		#cgrabber button[disabled],
		#cgrabber label[disabled],
		#cgrabber .not_implemented{
			filter: contrast(0.5);
			pointer-events: none;
		}
		#cgrabber dialog {
			display: none !important;
			border-radius: 1rem;
			border: solid 1px rgb(30 30 30);
			padding: 0;
		}
		#cgrabber dialog > * {
			padding: .5rem 1rem;
		}
		#cgrabber dialog ul {
			margin: 0;
			text-align: left;
			padding-inline: 1rem;
		}
		#cgrabber dialog > :first-child {
			display: block;
			border-bottom: solid 1px rgb(30 30 30);
			font-weight: bold;
		}
		#cgrabber dialog > :last-child {
			display: block;
			border-top: solid 1px rgb(30 30 30);
			font-weight: bold;
			cursor: pointer;
			transition: background 300ms ease;
		}
		#cgrabber dialog > :last-child:hover {
			background-color: red;
		}
		#cgrabber dialog:modal {
			display: block !important;
			position: absolute;
			top: 50%;
			left: 50%;
			min-width: 15rem;
			background-color: black;
			transform: translate(-50%, -50%);
		}
		#cgrabber dialog::backdrop {
			background: rgb(0 0 0 / .5)
		}
	`)

//# UI building
const new_button = (text, id='', order='') => {
	return $('<button></button>')
		.attr('id', id)
		.html(text)
		.css('order', order)
}
const $ui_wrapper = $('<div id="cgrabber"></div>')
const $json_info = $('<span id="json_info"></span>').text('No JSON loaded')
const $modal_info = $('<div id="#modal-info"></div>')
const $modal = $('<dialog></dialog>')
	.append($('<div>Chat Grabber</div>'))
	.append($modal_info)
	.append($('<div id="okay">Okay</div>').on('click', () => $modal[0].close()))
$ui_wrapper.append(
		$('<div id="header"></div>')
			.text(`> Chat Grabber ${ScriptVersion} <`)
	).append(
		$('<span>JSON:</span>')
	).append(
		$('<label for="json_input"></label>')
			.text('Load')
			.append(
				$('<input hidden type="file" id="json_input">')
					.on('change', JSONLoad)
			)
	).append(
		$json_info
	).append(
		$('<label for="save_uri"></label>')
			.append(
				$('<input hidden type="checkbox" id="save_uri" style="margin: 0; margin-right: 1ch" checked>')
					.on('change', (e) => {
						const $cb = $ui_wrapper.find('#save_uri')
						json_internal.save_uris = $cb[0].checked
						$ui_wrapper.find('label[for="save_uri"] > span')
							.text('Save images in-file: '  + ($cb[0].checked? '✔' : '✘'))
					})
			)
			.append(
				$('<span></span>')
					.text('Save images in-file: ' + (json_internal.save_uris? '✔' : '✘'))
			)
	).append(
		new_button('Download', 'json_output')
			.on('click', JSONDownload)
	).append(
		$modal
	).append(
		$('<span>Generate:</span>')
	).append(
		new_button('Current chat', 'gen_chat')
			.on('click', GrabChat)
	)/*.append(
		new_button('Current community, all chats<br>(ill-advised)', 'gen_cur_all')
			.on('click', GrabAllInCommunity)
	).append(
		new_button('All communities, all chats<br>(ill-advised)', 'gen_all_all')
			.on('click', GrabEverything)
			.addClass('not_implemented')
	)*/
.on('mouseenter', function(){window.jQuery(this).find('> :not(:first-child, dialog)').stop().show(300)})
.on('mouseleave', function(){window.jQuery(this).find('> :not(:first-child, dialog)').stop().hide(300)})

//# DOM access
const $chat = () => window.jQuery(document.querySelector('iframe')?.contentDocument)
const $msglist = () => $chat()?.find('.message-list')

//# Unbind $
$.noConflict()
window.addEventListener('load', () => {
	const $ = window.jQuery
	console.log('ChatGrabber: Loading...')

	$(document.head).append(css)
	$(document.body).append($ui_wrapper)
	$ui_wrapper.find('> :not(:first-child)').hide(0)

	console.log('ChatGrabber: Loaded.')
})

//# Helper functions
function UI(enabled) {
	enabled?
		$ui_wrapper.find('button, label').each(function(){this.removeAttribute('disabled')})
		:
		$ui_wrapper.find('button, label').each(function(){this.setAttribute('disabled', '')})
}
async function Notify(text, ok=true, replace=false) {
	const interval = setInterval(() => {
		if (!replace)
			if ($modal[0].open) return
		clearInterval(interval)
		$modal_info.html(text)
		ok?
			$modal.find('#okay').show()
			:
			$modal.find('#okay').hide()
		try {
			$modal[0].showModal()
		} catch(e) {}
	})
}
function DismissNotify() {
	$modal[0].close()
}
async function URL2URI(url, type, callback, onfail) {
	if (url == '') return
	const reader = new FileReader()
	reader.onload = (e) => {
		if (e.target.result == 'data:image/jpg;base64,PGh0bWw+CjxoZWFkPjx0aXRsZT40MDQgTm90IEZvdW5kPC90aXRsZT48L2hlYWQ+Cjxib2R5Pgo8aDE+NDA0IE5vdCBGb3VuZDwvaDE+Cjx1bD4KPGxpPkNvZGU6IE5vU3VjaEZpbGU8L2xpPgo8bGk+TWVzc2FnZTogVGhlIHNwZWNpZmllZCBmaWxlIGRvZXMgbm90IGV4aXN0LjwvbGk+CjwvdWw+Cjxoci8+CjwvYm9keT4KPC9odG1sPg==') {
			onfail()
			return
		}
		callback(e.target.result)
	}
	let err = false
	fetch(corsp + url)
		.then(res => res.blob())
		.then(blob => {
			reader.readAsDataURL(new Blob([blob], {type: type}))
		})
}
async function WaitFor(f) {
	return new Promise(async (resolve) => {
		setTimeout(() => {
			resolve(false)
		}, 5000)
		do {
			await new Promise(r => setTimeout(r, 16))
			if (f().length)
				break
		} while (true)
		resolve(true)
	})
}
//# JSON-handling functions
function JSONLoad() {
	const $ = window.jQuery
	//- Lock UI
	UI(false)
	//- Cache the button element
	const $load = $('#json_input')
	//- Read the file
	reader.readAsText($load[0].files[0])
	//- Clear the input
	$load[0].value = ''
	//- Update info and unlock UI
	UpdateInfo()
	UI(true)
}
function JSONDownload() {
	console.dir(json_internal)
	const file = new Blob([JSON.stringify(json_internal, null, '\t')], {type:'application/json'})
	const a = document.createElement('a')
	a.href = URL.createObjectURL(file)
	a.download = `amino-chat-compilation-v${ScriptVersion}.json`
	a.click()
}
async function UpdateInfo() {
	const comms = json_internal.communities.length
	const chats = json_internal.communities.reduce(
		(prev, cur) => prev += cur.chats.length, 0
	)
	const msgs = json_internal.communities.reduce(
		(prev, cur) => prev += cur.chats.reduce(
			(prev, cur) => prev += cur.history.length, 0
		), 0
	)
	$json_info.html(
		`${comms} communities<br>${chats} chats<br>${msgs} messages`)
}
function MigrateFile() {
	if (json_internal.cur_version == ScriptVersion) return
	if (parseFloat(json_internal.cur_version) > parseFloat(ScriptVersion)) {
		Notify("Your script is outdated in relation to the JSON file, and, as such, using them together is unadvised.")
	}
	Notify("Your JSON file is outdated; migrations will be applied.\nThe browser might hang for a few moments. Please wait.", false)
	UI(false)
	const initial_version = json_internal.cur_version
	let changes = ''
	//- 1.7 - Added DataURI saving for images (covers, icons, avatars and image/sticker messages)
	//- 2.0 - Added message bookmark field
	if (parseFloat(initial_version) <= 1.6 || parseFloat(initial_version) <= 1.7) {
		json_internal.cur_version = '2.0'
		if (parseFloat(initial_version) < 1.7)
			changes += `
			<b>1.7</b>
			<ul>
				<li>Added 'notes' field to communities and user chats.</li>
				<li>Added fields to store images (community and user icons/avatars and covers, message images/stickers) as DataURI (they'll be saved as Base64 strings directly into the JSON file).
				<ul>
					<li>Images already present in the file as links were automatically converted and stored if available. Some images may no longer be available.</li></ul>
			</ul>
			`
		if (parseFloat(initial_version) < 2.0) {
			changes += `
			<b>2.0</b>
			<ul>
				<li>Added 'bookmark' field to messages. It defaults to an empty string on all messages, but messages where this is filled will be marked for easy finding in the separate Chat History Viewer utility.</li>
				<li>Added 'save_uris' field to top-level object, which defaults to true, and indicates whether the user would like to save images as DataURI, which incurs a fairly significant file size increase.</li>
			</ul>
			`
			json_internal.save_uris = true
		}
		json_internal.communities.forEach((com, com_i) => {
			const new_com = new Community()
			new_com.init(com)
			new_com.genURIs(new_com.name)
			json_internal.communities[com_i] = new_com
			com.chats.forEach((chat, chat_i) => {
				const new_chat = new Chat()
				new_chat.init(chat)
				new_chat.genURIs(new_com.name+' > '+new_chat.username)
				json_internal.communities[com_i].chats[chat_i] = new_chat
				chat.history.forEach((msg, msg_i) => {
					const new_msg = new Message()
					new_msg.init(msg)
					new_msg.genURIs(new_com.name+' > '+new_chat.username+' > history['+msg_i+']')
					json_internal.communities[com_i].chats[chat_i].history[msg_i] = new_msg
				})
			})
		})
	}
	Notify(`Your JSON file has had its structure and fields updated from version ${initial_version} to ${ScriptVersion}.<br>The following changes were applied:<br><br>${changes}`, true, true)
	UI(true)

}
//# Grabber functions
function GrabCommunity() {
	try {
		//- Lock UI
		UI(false)
		//- Cache queries
		const chat = $chat()
		//- Create entry and grab all info
		const entry = new Community({
			name: JSON.stringify(chat.find('.community-title').text().trim()),
			link: chat.find('.community-title :first-child')[0].href,
			icon: chat.find('.community-title img.logo')[0].src
		})
		//- Generate URIs, if set to
		if (json_internal.save_uris)
			entry.genURIs()
		//- Append community to structure communities
		json_internal.communities.push(entry)
		//- Update info and return
		UpdateInfo()
		return entry
	} catch(e) {
		//! This should not happen under normal circumstances
		console.error('!! This should not happen under normal circumstances !!')
		console.error(e)
		UI(true)
		return undefined
	}
}
async function GrabChat() {
	const $ = window.jQuery
	try {
		//- Lock UI
		UI(false)
		Notify('Please wait while it works...', false)
		//- Cache queries
		const chat = $chat()
		const msglist = $msglist()
		//- Find community in structure, or generate it
		const com_link = chat.find('.community-title :first-child')[0].href
		let com = undefined;
		let com_i = undefined;
		json_internal.communities.forEach((c, ci) => {
			if (c.link != com_link) return
			com = c
			com_i = ci
			return
		})
		if (com == undefined)
			com = GrabCommunity()
		if (com == undefined)
			throw 'Community not found in file or on page, despite being open in the chat window.'
		//- Open profile panel, if not already open
		if (chat.find('.user-profile').length == 0) {
			chat.find('.user-message:not(.from-me)').find('.message-author.cover-img')[0].click()
			await WaitFor(() => chat.find('.user-profile'))
		}
		await WaitFor(() => chat.find('.user-profile .user-link'))
		//- If it's a banned/deleted account, skip
		if (chat.find('.thread-title').text().trim() == '-')
			return undefined
		//- Create entry and grab all info
		const entry_c = new Chat({
			username: chat.find('.thread-title').text().trim(),
			link: chat.find('.user-profile .user-link')[0].href,
			avatar: chat.find('.avatar')[0].src,
			cover: chat.find('.user-cover .img-cover')[0].src
		})
		//- Generate URIs, if set to
		if (json_internal.save_uris)
			entry_c.genURIs()
		//- Scroll up as far as possible
		do {
			const count = msglist[0].childElementCount
			msglist[0].scrollTo(top)
			await new Promise(r => setTimeout(r, LoadDelay))
			if (msglist[0].childElementCount == count)
				break
		} while (true)
		//- Grab oldest timestamp
		entry_c.oldest_timestamp = msglist.find('.timestamp')[0].innerText
		//- Grab messages
		msglist.find('.user-message').each(function() {
			const $this = $(this)
			const entry_m = new Message({
				user: ($this.hasClass('from-me')? 'Me' : entry_c.username)
			})
			//* Sticker or image
			if ($this.find('.sticker-message, .img-msg').length) {
				if ($this.find('.sticker-message').length)
					entry_m.type = 'sticker'
				if ($this.find('.img-msg').length)
					entry_m.type = 'image'
				entry_m.content = $this.find('.sticker-message > :first-child, .img-msg > :first-child')[0].src
			}
			//* Audio
			else if ($this.find('.voice-message-container').length) {
				entry_m.type = 'audio'
				entry_m.content = $this.find('.voice-message-container audio')[0].src
			}
			//* Text
			else if ($this.find('.text-msg').length) {
				entry_m.type = 'text'
				entry_m.content = $this.find('.text-msg').html()
				//- Replace tags and restore formatting
				entry_m.content = entry_m.content
					.replaceAll('<p>','')
					.replaceAll('</p>','\n')
					.replaceAll('<p class="', '[')
					.replaceAll('">', ']')
					.replaceAll('center', 'C')
					.replaceAll('italic', 'I')
					.replaceAll('bolder', 'B')
					.replaceAll('strike', 'S')
					.replaceAll('underline', 'U')
				//- Remove spaces from formatting brackets
				const replace = entry_m.content.slice(entry_m.content.indexOf('[')+1,entry_m.content.indexOf(']'))
				if (replace.length != entry_m.content.length-1) {
					entry_m.content = entry_m.content.replace(replace, replace.replaceAll(' ', ''))
				}
				//- Remove trailing line break
				entry_m.content = entry_m.content.slice(0, entry_m.content.length-1)
				//- Try to determine whether the message is a comment
				if (
					entry_m.content.trim().startsWith('|') ||
					entry_m.content.trim().startsWith('(') ||
					entry_m.content.trim().startsWith('/') ||
					entry_m.content.trim().startsWith(`\\`) ||
					entry_m.content.trim().endsWith('|') ||
					entry_m.content.trim().endsWith('(') ||
					entry_m.content.trim().endsWith('/') ||
					entry_m.content.trim().endsWith(`\\`)
				) entry_m.type += ' comment'
			}
			//* Unknown
			else {
				entry_m.type = 'unknown'
				Message.ErrURI(com.name)
			}
			//- Generate and save URIs, if set to
			if (json_internal.save_uris)
				entry_m.genURIs()
			//- Append message to chat history and update info
			entry_c.history.push(entry_m)
			UpdateInfo()
		})
		//- Check if chat with same link already exists
		if (com.chats.map(c => c.link).includes(entry_c.link)) {
			//- If it does, check if it has more or less messages
			com.chats.forEach((c, ci) => {
				if (c.link != entry_c.link) return
				//- If it has less, replace it
				if (c.history.length < entry_c.history.length)
					json_internal.communities[com_i].chats[ci] = entry_c
				//! The only case where this may backfire is if the user coudl keep sending
				//! messages to a chat with a banned account, which is, as of writing, not
				//! possible.
			})
		}
		//- Else, append chat to community chats
		else
			com.chats.push(entry_c)
	} catch(e) {
		console.error(e)
		UI(true)
		Notify('Something went wrong.<br>Check the console for more information.', true, true)
		return false
	}
	//- Update info and Unlock UI
	UpdateInfo()
	UI(true)
	DismissNotify()
	//- Finally return
	return true
}
async function GrabAllInCommunity() {
	try {
		//- Lock UI
		UI(false)
		//- Cache queries
		const chat = $chat()
		const chatrooms = chat.find('.thread-list .animation-list').find('.chatroom')
		//- Keep track of errors
		let errors = 0
		//- Iterate over each room
		for (let i=0; i<chatrooms.length; i++) {
			if (errors > 3) {
				Notify(`${errors} sequential failed attempts to grab chats.<br>You are probably being rate-limited. If so, messages won't load for a short while and you may be unable to send messages, until the limit is revoked by the server.<br>Try grabbing chats one by one, and if errors continue, slow down.`)
			}
			console.log(`GrabAllInCommunity: Getting chatroom ${i}:`)
			chatrooms.get(i)
			//- Select the chatroom and wait for it to load
			chatrooms.get(i).click()
			await new Promise(r => setTimeout(r, LoadDelay))
			//- Grab the now-current chat
			GrabChat()? (() => {
				errors++
				console.warn(`GrabAllInCommunity: ${errors} sequential errors.`)
			}) : errors = 0
			console.log('GrabAllInCommunity: Finished. Delaying...')
			await new Promise(r => setTimeout(r, LoadDelay))
		}
	} catch(e) {
		console.error(e)
		UI(true)
	}
}
function GrabEverything() {}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址