// ==UserScript==
// @name YouTube聊天观察哨
// @namespace https://greasyfork.org/scripts/414521
// @version 0.8.2
// @description 观测指定用户的聊天消息;报告被删除的聊天消息;观测当前用户的消息状态;一键检测SuperChat状态
// @author nyakarin
// @match https://www.youtube.com/live_chat*
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.prod.js
// @grant unsafeWindow
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_notification
// @grant window.focus
// ==/UserScript==
if (!unsafeWindow.Vue) {
unsafeWindow.Vue = Vue;
}
(async function () {
"use strict";
const spyVersion = "0.8.2";
const defaultSettings = {
cv6Port: 5000,
reportedChatItemCount: 3,
reservedChatItemCount: 20,
};
const settings = Vue.ref(Object.assign({}, defaultSettings));
const settingsValueKey = "settings";
const chatItemsDeletedByAuthorEventValueKey = "__chatItemsDeletedByAuthorEvent";
function last(arr) {
return arr[arr.length - 1];
}
function waitFor(fn, timeout = 100, tryTimes = 100) {
let times = 0;
return new Promise((resolve, reject) => {
const intervalId = setInterval(() => {
try {
const result = fn();
if (result !== undefined) {
clearInterval(intervalId);
resolve(result);
}
else {
times += 1;
if (times >= tryTimes) {
clearInterval(intervalId);
reject("超时");
}
}
}
catch (error) {
clearInterval(intervalId);
reject(error);
}
}, timeout);
});
}
function getByPath(obj, ...paths) {
paths.forEach((path) => {
if (obj != null) {
obj = obj[path];
}
else {
return undefined;
}
});
return obj;
}
function toTimeString(timestamp) {
const date = new Date(timestamp);
let hourStr = date.getHours() + "";
if (hourStr.length === 1) {
hourStr = "0" + hourStr;
}
let minuteStr = date.getMinutes() + "";
if (minuteStr.length === 1) {
minuteStr = "0" + minuteStr;
}
return `${hourStr}:${minuteStr}`;
}
function toDurationString(duration) {
const totalSeconds = Math.round(duration / 1000);
const seconds = totalSeconds % 60;
const minutes = (totalSeconds - seconds) / 60;
let minuteStr = minutes + "";
if (minuteStr.length === 1) {
minuteStr = "0" + minuteStr;
}
let secondStr = seconds + "";
if (secondStr.length === 1) {
secondStr = "0" + secondStr;
}
return `${minuteStr}:${secondStr}`;
}
function toMessageText(runs) {
let text = "";
if (Array.isArray(runs)) {
runs.forEach((run) => {
if (run) {
if (run.text) {
text += run.text;
}
else if (run.emoji) {
const emojiText = getByPath(run.emoji, "shortcuts", 0);
text += emojiText ? emojiText : ":emoji:";
}
}
});
}
return text;
}
class Messager {
constructor() {
this._callbacks = [];
}
emit(message) {
this._callbacks.forEach((callback) => {
try {
callback(message);
}
catch (error) {
console.error(error);
}
});
}
subscribe(callback) {
this._callbacks.push(callback);
return () => {
this._callbacks = this._callbacks.filter((cb) => cb !== callback);
};
}
}
let ChatItemDeletedType;
(function (ChatItemDeletedType) {
ChatItemDeletedType[ChatItemDeletedType["ByItem"] = 1] = "ByItem";
ChatItemDeletedType[ChatItemDeletedType["ByAuthor"] = 2] = "ByAuthor";
})(ChatItemDeletedType || (ChatItemDeletedType = {}));
const ChatItemDeletedTypeIcons = {
[ChatItemDeletedType.ByItem]: "❌",
[ChatItemDeletedType.ByAuthor]: "🚫",
};
const ChatItemDeletedLinkIcon = "🔗";
const EmojiNumberIcons = [
"0️⃣",
"1️⃣",
"2️⃣",
"3️⃣",
"4️⃣",
"5️⃣",
"6️⃣",
"7️⃣",
"8️⃣",
"9️⃣",
"🔟",
"*️⃣",
];
const isReplay = window.location.pathname === "/live_chat_replay";
const currentChannelId = (() => {
var _a;
const serviceTrackingParams = getByPath(unsafeWindow.ytInitialData, "responseContext", "serviceTrackingParams");
if (Array.isArray(serviceTrackingParams)) {
const guidedHelp = serviceTrackingParams.find((param) => (param === null || param === void 0 ? void 0 : param.service) === "GUIDED_HELP");
if (guidedHelp) {
const guidedHelpParams = guidedHelp.params;
if (Array.isArray(guidedHelpParams)) {
const channelId = (_a = guidedHelpParams.find((param) => (param === null || param === void 0 ? void 0 : param.key) === "creator_channel_id")) === null || _a === void 0 ? void 0 : _a.value;
if (typeof channelId === "string") {
return channelId;
}
}
}
}
return null;
})();
const currentAuthorName = (() => {
var _a;
const viewerName = (_a = getByPath(unsafeWindow.ytInitialData, "continuationContents", "liveChatContinuation", "viewerName")) !== null && _a !== void 0 ? _a : getByPath(unsafeWindow.ytInitialData, "contents", "liveChatRenderer", "viewerName");
return typeof viewerName === "string" ? viewerName : null;
})();
const currentAuthorPhoto = (() => {
var _a;
const liveChatContinuation = (_a = getByPath(unsafeWindow.ytInitialData, "continuationContents", "liveChatContinuation")) !== null && _a !== void 0 ? _a : getByPath(unsafeWindow.ytInitialData, "contents", "liveChatRenderer");
const thumbnails = getByPath(liveChatContinuation, "actionPanel", "liveChatMessageInputRenderer", "authorPhoto", "thumbnails");
if (Array.isArray(thumbnails)) {
const thumbnail = last(thumbnails);
const url = thumbnail === null || thumbnail === void 0 ? void 0 : thumbnail.url;
return typeof url === "string" ? url : undefined;
}
return undefined;
})();
const chatItemById = Vue.reactive(new Map());
const chatItemsByChannelId = Vue.reactive(new Map());
const chatItemInfoById = new Map();
const liveChatMessager = new Messager();
const liveChatAddChatItemCountMessager = new Messager();
const sentMessageIdSet = new Set();
const sendMessageIgnoredMessager = new Messager();
const getCartErrorMessager = new Messager();
let fetchNative;
let listenGetLiveChat = false;
let listenSendMessage = false;
let interceptGetCart = false;
let currentGetLiveChatCallNumber = 0;
let currentGetLiveChatTimestamp = 0;
function patchFetch() {
const getLiveChatPath = isReplay
? "/get_live_chat_replay"
: "/get_live_chat";
const sendMessagePath = "/send_message";
const getCartPath = "/get_cart";
function patchResponse(response, handler) {
const jsonNative = response.json;
response.json = async function (...args) {
const json = await jsonNative.apply(this, args);
try {
const result = handler(json);
if (result !== undefined) {
return result;
}
}
catch (error) {
console.error(error);
}
return json;
};
const textNative = response.text;
response.text = async function (...args) {
const text = await textNative.apply(this, args);
try {
const json = JSON.parse(text);
const result = handler(json);
if (result !== undefined) {
return JSON.stringify(result, undefined, 2);
}
}
catch (error) {
console.error(error);
}
return text;
};
}
fetchNative = unsafeWindow.fetch;
unsafeWindow.fetch = async function (...args) {
const response = await fetchNative.apply(this, args);
let pathname;
try {
const url = new URL(response.url);
pathname = url.pathname;
}
catch (error) {
pathname = "";
}
if (listenGetLiveChat && pathname.endsWith(getLiveChatPath)) {
patchResponse(response, (json) => {
currentGetLiveChatCallNumber += 1;
currentGetLiveChatTimestamp = Date.now();
let actions = getByPath(json, "continuationContents", "liveChatContinuation", "actions");
if (isReplay && Array.isArray(actions)) {
actions = actions
.map((action) => getByPath(action, "replayChatItemAction", "actions"))
.flat();
}
if (Array.isArray(actions)) {
const addChatItemCount = actions.filter((action) => action === null || action === void 0 ? void 0 : action.addChatItemAction).length;
if (addChatItemCount > 0) {
liveChatAddChatItemCountMessager.emit({
count: addChatItemCount,
timestamp: Date.now(),
});
}
actions.forEach((action) => {
var _a, _b, _c, _d;
const addedMessageRenderer = getByPath(action, "addChatItemAction", "item", "liveChatTextMessageRenderer");
if (addedMessageRenderer) {
const id = getByPath(addedMessageRenderer, "id");
const channelId = getByPath(addedMessageRenderer, "authorExternalChannelId");
const authorName = getByPath(addedMessageRenderer, "authorName", "simpleText");
const timestampUsec = getByPath(addedMessageRenderer, "timestampUsec");
const messageRuns = getByPath(addedMessageRenderer, "message", "runs");
if (typeof id === "string" &&
typeof channelId === "string" &&
typeof authorName === "string" &&
typeof timestampUsec === "string") {
if (!chatItemsByChannelId.has(channelId)) {
chatItemsByChannelId.set(channelId, []);
}
const chatItems = chatItemsByChannelId.get(channelId);
if (chatItems.length >= settings.value.reservedChatItemCount) {
const item = chatItems.shift();
if (item) {
chatItemById.delete(item.id);
chatItemInfoById.delete(item.id);
}
}
const item = {
id,
channelId,
authorName,
time: toTimeString(parseInt(timestampUsec, 10) / 1000),
message: toMessageText(messageRuns),
};
chatItems.push(item);
chatItemById.set(item.id, item);
chatItemInfoById.set(item.id, {
callNumber: currentGetLiveChatCallNumber,
timestamp: currentGetLiveChatTimestamp,
});
liveChatMessager.emit({
type: "add",
item,
});
}
}
const deletedAction = getByPath(action, "markChatItemAsDeletedAction");
if (deletedAction) {
const itemId = getByPath(deletedAction, "targetItemId");
const deletedStateMessageRuns = getByPath(deletedAction, "deletedStateMessage", "runs");
if (typeof itemId === "string") {
const item = chatItemById.get(itemId);
if (item) {
item.deletedType =
((_a = item.deletedType) !== null && _a !== void 0 ? _a : 0) | ChatItemDeletedType.ByItem;
item.deletedState = toMessageText(deletedStateMessageRuns);
const chatItemInfo = chatItemInfoById.get(item.id);
if (chatItemInfo) {
(_b = item.deletedEfficiency) !== null && _b !== void 0 ? _b : (item.deletedEfficiency = currentGetLiveChatCallNumber - chatItemInfo.callNumber);
(_c = item.deletedDuration) !== null && _c !== void 0 ? _c : (item.deletedDuration = currentGetLiveChatTimestamp - chatItemInfo.timestamp);
}
}
liveChatMessager.emit({
type: "delete",
itemId,
});
}
}
const byAuthorDeletedAction = getByPath(action, "markChatItemsByAuthorAsDeletedAction");
if (byAuthorDeletedAction) {
const channelId = getByPath(byAuthorDeletedAction, "externalChannelId");
const deletedStateMessageRuns = getByPath(byAuthorDeletedAction, "deletedStateMessage", "runs");
if (typeof channelId === "string") {
const chatItems = chatItemsByChannelId.get(channelId);
const lastChatItemId = chatItems
? (_d = last(chatItems)) === null || _d === void 0 ? void 0 : _d.id : undefined;
chatItems === null || chatItems === void 0 ? void 0 : chatItems.forEach((item) => {
var _a, _b, _c;
item.deletedType =
((_a = item.deletedType) !== null && _a !== void 0 ? _a : 0) | ChatItemDeletedType.ByAuthor;
item.deletedState = toMessageText(deletedStateMessageRuns);
const chatItemInfo = chatItemInfoById.get(item.id);
if (chatItemInfo) {
(_b = item.deletedDuration) !== null && _b !== void 0 ? _b : (item.deletedDuration = currentGetLiveChatTimestamp - chatItemInfo.timestamp);
if (item.id === lastChatItemId) {
(_c = item.deletedEfficiency) !== null && _c !== void 0 ? _c : (item.deletedEfficiency = currentGetLiveChatCallNumber -
chatItemInfo.callNumber);
}
}
});
liveChatMessager.emit({
type: "deleteByAuthor",
channelId,
});
if (lastChatItemId) {
GM_setValue(chatItemsDeletedByAuthorEventValueKey, `${channelId}|${lastChatItemId}`);
}
}
}
});
}
});
}
if (listenSendMessage && pathname.endsWith(sendMessagePath)) {
patchResponse(response, (json) => {
const actions = getByPath(json, "actions");
if (Array.isArray(actions)) {
actions.forEach((action) => {
const addedMessageRenderer = getByPath(action, "addChatItemAction", "item", "liveChatTextMessageRenderer");
if (addedMessageRenderer) {
const id = getByPath(addedMessageRenderer, "id");
if (typeof id === "string") {
sentMessageIdSet.add(id);
sendMessageIgnoredMessager.emit(false);
}
}
});
}
else {
sendMessageIgnoredMessager.emit(true);
}
});
}
if (interceptGetCart && pathname.endsWith(getCartPath)) {
patchResponse(response, (json) => {
const errorTextRuns = getByPath(json, "messageRenderer", "liveChatErrorMessageRenderer", "errorText", "runs");
if (!errorTextRuns) {
getCartErrorMessager.emit(null);
return {
responseContext: getByPath(json, "responseContext"),
trackingParams: getByPath(json, "trackingParams"),
messageRenderer: {
liveChatErrorMessageRenderer: {
errorText: {
runs: [
{
text: "✔️",
},
],
},
},
},
};
}
else {
getCartErrorMessager.emit(toMessageText(errorTextRuns));
}
});
}
return response;
};
}
function unpatchFetch() {
if (fetchNative) {
unsafeWindow.fetch = fetchNative;
}
fetchNative = undefined;
}
function getChatItemDeletedTypeIcon(item) {
return item.deletedType
? item.deletedEfficiency == null
? ChatItemDeletedLinkIcon
: item.deletedType & ChatItemDeletedType.ByItem
? ChatItemDeletedTypeIcons[ChatItemDeletedType.ByItem]
: item.deletedType & ChatItemDeletedType.ByAuthor
? ChatItemDeletedTypeIcons[ChatItemDeletedType.ByAuthor]
: null
: null;
}
function getChatItemDeletedEfficiencyIcon(item) {
return item.deletedEfficiency != null
? EmojiNumberIcons[Math.min(item.deletedEfficiency, 11)]
: "⬜";
}
function copyToClipboard(items) {
let str = "";
items.forEach((item) => {
if (item.deletedType) {
str +=
getChatItemDeletedTypeIcon(item) +
getChatItemDeletedEfficiencyIcon(item) +
" ";
}
if (item.deletedState) {
str += item.deletedState + " ";
}
str += `[${item.time}]` + " " + item.authorName;
if (item.message) {
str += ": " + item.message;
}
str += "\r\n";
});
GM_setClipboard(str);
}
class CV6Connection {
constructor() {
this.status = Vue.ref("closed");
this.error = Vue.ref(null);
this._watchingChannelIds = [];
}
connect() {
this.disconnect();
this.error.value = null;
const websocket = new WebSocket(`ws://localhost:${settings.value.cv6Port}`);
this._websocket = websocket;
this._websocket.addEventListener("open", () => {
if (!this._isCurrentWebSocket(websocket)) {
return;
}
this.status.value = "open";
this._sendMessage({ type: "hello" });
});
this._websocket.addEventListener("close", (ev) => {
if (!this._isCurrentWebSocket(websocket)) {
return;
}
if (ev.code === 1006) {
this.error.value = "连接失败";
}
else if (ev.code === 4444) {
this.error.value = "连接已被占用";
}
this.status.value = "closed";
this.disconnect();
});
this._websocket.addEventListener("message", (ev) => {
if (!this._isCurrentWebSocket(websocket)) {
return;
}
try {
const message = JSON.parse(ev.data);
if ((message === null || message === void 0 ? void 0 : message.command) === "monitor-channel") {
this._watchingChannelIds = message.payload;
}
}
catch (error) {
console.error(error);
}
});
this._subscriber = liveChatMessager.subscribe((msg) => {
var _a;
if (!this._isCurrentWebSocket(websocket)) {
return;
}
if (msg.type === "add") {
if (this._watchingChannelIds.includes(msg.item.channelId)) {
this._sendMessage({
type: "message-added",
message: msg.item,
});
}
}
else if (msg.type === "delete") {
const item = chatItemById.get(msg.itemId);
if (item && this._watchingChannelIds.includes(item.channelId)) {
this._sendMessage({
type: "message-deleted",
message: item,
});
}
}
else if (msg.type === "deleteByAuthor") {
if (this._watchingChannelIds.includes(msg.channelId)) {
this._sendMessage({
type: "messages-deleted-by-author",
channelId: msg.channelId,
messages: (_a = chatItemsByChannelId.get(msg.channelId)) !== null && _a !== void 0 ? _a : [],
});
}
}
});
}
disconnect() {
if (this._websocket) {
this._websocket.close();
this._websocket = undefined;
}
if (this._subscriber) {
this._subscriber();
this._subscriber = undefined;
}
this._watchingChannelIds = [];
}
_sendMessage(message) {
var _a;
(_a = this._websocket) === null || _a === void 0 ? void 0 : _a.send(JSON.stringify(Object.assign({ __spy: spyVersion, timestamp: Date.now() }, message)));
}
_isCurrentWebSocket(websocket) {
return !this._websocket || websocket === this._websocket;
}
}
const cv6Connection = new CV6Connection();
function createApp() {
const app = Vue.createApp({
setup() {
const initialized = Vue.ref(false);
const visible = Vue.ref(false);
const toggleVisible = () => {
visible.value = !visible.value;
if (visible.value) {
initialized.value = true;
}
};
const toggleVisibleButtonTitle = Vue.computed(() => initialized.value ? "观察哨(已启动)" : "观察哨(未启动)");
const toggleVisibleButtonContentStyle = Vue.computed(() => !initialized.value
? {
opacity: 0.33,
}
: {});
const chatItemsDeletedByAuthor = Vue.ref(false);
const sendMessageIgnoredCount = Vue.ref(0);
const sendMessageIgnoredTitle = Vue.computed(() => sendMessageIgnoredCount.value
? `发送消息被忽略(连续${sendMessageIgnoredCount.value}次)`
: undefined);
const superChatCheckVisible = Vue.ref(false);
Vue.watch(visible, (value, oldValue) => {
if (value || oldValue === undefined) {
superChatCheckVisible.value = !!document.querySelector("yt-live-chat-product-button-renderer[icon-id=purchase_super_chat]");
}
}, {
immediate: true,
});
const superChatCheckRunning = Vue.ref(false);
const superChatCheckError = Vue.ref(undefined);
const superChatCheckText = Vue.ref(undefined);
const superChatCheckTitle = Vue.computed(() => superChatCheckRunning.value
? "正在检测SuperChat状态..."
: "检测SuperChat状态");
const checkSuperChat = async (text) => {
var _a, _b, _c;
if (superChatCheckRunning.value) {
return {
error: "正在检测SuperChat状态...",
};
}
let resultReceived = false;
superChatCheckText.value = undefined;
superChatCheckError.value = undefined;
superChatCheckRunning.value = true;
interceptGetCart = true;
const subscriber = getCartErrorMessager.subscribe((message) => {
resultReceived = true;
if (message) {
superChatCheckError.value = message;
}
else {
superChatCheckError.value = null;
}
});
try {
try {
const superChatEl = await waitFor(() => { var _a; return (_a = document.querySelector("yt-live-chat-product-button-renderer[icon-id=purchase_super_chat] > a")) !== null && _a !== void 0 ? _a : undefined; }, 1, 1);
superChatEl.click();
}
catch (error) {
throw "用户未登录或SuperChat不可用";
}
const sendButtonEl = await waitFor(() => { var _a; return (_a = document.querySelector("#button.yt-live-chat-message-buy-flow-renderer")) !== null && _a !== void 0 ? _a : undefined; });
if (text === true) {
text = "";
const childNodes = (_b = (_a = document.querySelector("yt-live-chat-text-input-field-renderer.yt-live-chat-message-input-renderer > #input")) === null || _a === void 0 ? void 0 : _a.childNodes) !== null && _b !== void 0 ? _b : [];
childNodes.forEach((node) => {
var _a;
if (node instanceof HTMLImageElement) {
if (node.classList.contains("emoji")) {
const alt = node.alt;
if (alt) {
if (/\p{Extended_Pictographic}/u.test(alt)) {
text += alt;
}
else {
text += `:${alt}:`;
}
}
}
}
else {
text += (_a = node.textContent) !== null && _a !== void 0 ? _a : "";
}
});
text = text.trim();
}
if (text) {
superChatCheckText.value = text;
const inputEl = document.querySelector("yt-live-chat-text-input-field-renderer.yt-live-chat-paid-message-renderer > #input");
if (inputEl) {
inputEl.textContent = "";
const pasteEvent = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
(_c = pasteEvent.clipboardData) === null || _c === void 0 ? void 0 : _c.items.add(text, "text/plain");
inputEl.dispatchEvent(pasteEvent);
}
}
sendButtonEl.click();
await waitFor(() => (resultReceived ? resultReceived : undefined));
const closeButtonEl = await waitFor(() => { var _a; return (_a = document.querySelector("#close-button.yt-live-chat-message-buy-flow-renderer")) !== null && _a !== void 0 ? _a : undefined; });
closeButtonEl.click();
}
catch (error) {
superChatCheckError.value =
typeof error === "string" ? error : "未知错误";
}
finally {
superChatCheckRunning.value = false;
interceptGetCart = false;
subscriber();
}
return { error: superChatCheckError.value || null };
};
let chatItemsDeletedByAuthorListenerId;
let sendMessageIgnoredSubscriber;
Vue.onMounted(() => {
patchFetch();
listenSendMessage = true;
unsafeWindow.checkSuperChat = checkSuperChat;
if (currentChannelId) {
chatItemsDeletedByAuthorListenerId = GM_addValueChangeListener(chatItemsDeletedByAuthorEventValueKey, (name, oldValue, newValue) => {
if (typeof newValue !== "string") {
return;
}
const [channelId, lastChatItemId] = newValue.split("|");
if (!chatItemsDeletedByAuthor.value &&
channelId === currentChannelId &&
sentMessageIdSet.has(lastChatItemId)) {
chatItemsDeletedByAuthor.value = true;
GM_notification({
text: `观测到用户 ${currentAuthorName !== null && currentAuthorName !== void 0 ? currentAuthorName : currentChannelId} 所有消息被删除,该用户可能被ban`,
image: currentAuthorPhoto,
onclick: () => {
window.focus();
},
});
}
});
}
sendMessageIgnoredSubscriber = sendMessageIgnoredMessager.subscribe((ignored) => {
if (ignored) {
sendMessageIgnoredCount.value += 1;
}
else {
chatItemsDeletedByAuthor.value = false;
sendMessageIgnoredCount.value = 0;
}
});
});
Vue.onUnmounted(() => {
unpatchFetch();
listenSendMessage = false;
delete unsafeWindow.checkSuperChat;
if (chatItemsDeletedByAuthorListenerId) {
GM_removeValueChangeListener(chatItemsDeletedByAuthorListenerId);
chatItemsDeletedByAuthorListenerId = undefined;
}
if (sendMessageIgnoredSubscriber) {
sendMessageIgnoredSubscriber();
sendMessageIgnoredSubscriber = undefined;
}
});
return {
initialized,
visible,
toggleVisible,
toggleVisibleButtonTitle,
toggleVisibleButtonContentStyle,
chatItemsDeletedByAuthor,
sendMessageIgnoredCount,
sendMessageIgnoredTitle,
superChatCheckVisible,
superChatCheckRunning,
superChatCheckError,
superChatCheckTitle,
superChatCheckText,
checkSuperChat,
};
},
template: `
<span v-if="sendMessageIgnoredCount" :title="sendMessageIgnoredTitle">⚠️</span>
<span v-if="chatItemsDeletedByAuthor" title="观测到用户所有消息被删除,当前用户可能被ban">🚫</span>
<span v-if="superChatCheckError" :title="'无法发送SuperChat:' + superChatCheckError">❌</span>
<span v-if="superChatCheckError === null" title="可以发送SuperChat">{{superChatCheckText ? '🉑' : '✔️'}}</span>
<button v-if="superChatCheckVisible" :title="superChatCheckTitle" :disabled="superChatCheckRunning" @click="checkSuperChat(true)"><span>💵</span></button>
<button :title="toggleVisibleButtonTitle" @click="toggleVisible"><span :style="toggleVisibleButtonContentStyle">👀</span></button>
<app-main v-if="initialized" :visible="visible" />
`,
});
app.component("app-main", {
props: ["visible"],
setup(props) {
const chatRestricted = Vue.ref(false);
Vue.watch(() => props.visible, (value) => {
if (value) {
chatRestricted.value = !!document.querySelector("yt-live-chat-restricted-participation-renderer");
}
}, {
immediate: true,
});
const rootStyle = Vue.computed(() => ({
boxSizing: "border-box",
position: "absolute",
top: "48px",
zIndex: 2000,
height: isReplay
? "calc(100vh - 48px)"
: chatRestricted.value
? "calc(100vh - 96px)"
: "calc(100vh - 160px)",
width: "100vw",
display: props.visible ? "flex" : "none",
flexDirection: "column",
padding: "5px",
fontSize: "14px",
backgroundColor: "#f9f9f9",
}));
Vue.onMounted(() => {
listenGetLiveChat = true;
});
Vue.onUnmounted(() => {
listenGetLiveChat = false;
});
let userLiveChatSubscriber;
const onUserLiveChatRegister = (chatProps) => {
const filterAuthors = Vue.computed(() => typeof chatProps.filter === "string"
? chatProps.filter
.split("|")
.map((author) => author.trim())
.filter(Boolean)
: []);
userLiveChatSubscriber = liveChatMessager.subscribe((message) => {
if (message.type === "add") {
const item = message.item;
if (typeof chatProps.filter === "string"
? filterAuthors.value.includes(item.authorName) ||
filterAuthors.value.includes(item.channelId)
: chatProps.filter.test(item.authorName)) {
chatProps.items.push(item);
}
}
});
};
const onUserLiveChatUnregister = () => {
if (userLiveChatSubscriber) {
userLiveChatSubscriber();
userLiveChatSubscriber = undefined;
}
};
const userLiveChatTextFilterPlaceholder = '请输入用户名或频道ID,以"|"分隔';
const userLiveChatTextFilterHelp = "普通模式:\r\n" +
'输入以"|"分隔的用户名或频道ID,观测对应用户的新到消息\r\n' +
"示例:foo|bar|baz\r\n" +
"\r\n" +
"正则表达式模式:\r\n" +
'输入"/pattern/flags",观测用户名符合该正则表达式的用户的新到消息\r\n' +
"示例:/foo[bar]+/i";
const deletedLiveChatAddedChatItemIdSet = new Set();
let deletedLiveChatSubscriber;
const onDeletedLiveChatRegister = (chatProps) => {
deletedLiveChatSubscriber = liveChatMessager.subscribe((message) => {
if (message.type === "delete") {
const item = chatItemById.get(message.itemId);
if (item && !deletedLiveChatAddedChatItemIdSet.has(item.id)) {
deletedLiveChatAddedChatItemIdSet.add(item.id);
chatProps.items.push(item);
}
}
if (message.type === "deleteByAuthor") {
const chatItems = chatItemsByChannelId.get(message.channelId);
if (chatItems) {
const reportedChatItems = chatItems
.slice(Math.max(chatItems.length - settings.value.reportedChatItemCount, 0))
.reverse();
reportedChatItems.forEach((item, index) => {
if (!deletedLiveChatAddedChatItemIdSet.has(item.id)) {
deletedLiveChatAddedChatItemIdSet.add(item.id);
const insertBeforeItem = reportedChatItems[index - 1];
const insertBeforeIndex = chatProps.items.lastIndexOf(insertBeforeItem);
if (insertBeforeIndex >= 0) {
chatProps.items.splice(insertBeforeIndex, 0, item);
}
else {
chatProps.items.push(item);
}
}
});
}
}
});
};
const onDeletedLiveChatUnregister = () => {
if (deletedLiveChatSubscriber) {
deletedLiveChatSubscriber();
deletedLiveChatSubscriber = undefined;
}
};
const deletedLiveChatTextFilterPlaceholder = "请输入需要被包含或排除的关键词,以空格分隔";
const deletedLiveChatTextFilterHelp = "普通模式:\r\n" +
"输入以空格分隔的关键词,过滤出符合所有条件的消息(大小写不敏感)\r\n" +
'默认为包含关键词,在前面加"-"将其变为排除关键词\r\n' +
"示例:foo bar -baz\r\n" +
"\r\n" +
"正则表达式模式:\r\n" +
'输入"/pattern/flags",过滤出符合该正则表达式的消息\r\n' +
"示例:/^.{3,20}$/";
const deletedLiveChatTextFilterFn = (() => {
const textFilter = Vue.ref("");
const keywords = Vue.computed(() => typeof textFilter.value === "string"
? textFilter.value
.split(" ")
.map((e) => e.trim().toLowerCase())
.filter(Boolean)
: []);
const includeKeywords = Vue.computed(() => keywords.value.filter((e) => !e.startsWith("-")));
const excludeKeywords = Vue.computed(() => keywords.value
.filter((e) => e.startsWith("-"))
.map((e) => e.substring(1)));
return (items, filter) => {
if (typeof filter === "string") {
textFilter.value = filter;
return items.filter((item) => {
const message = item.message.toLowerCase();
return (includeKeywords.value.every((keyword) => message.includes(keyword)) &&
excludeKeywords.value.every((keyword) => !message.includes(keyword)));
});
}
else {
return items.filter((item) => filter.test(item.message));
}
};
})();
const cv6ConnectionStatus = Vue.computed(() => cv6Connection.status.value);
const cv6ConnectionError = Vue.computed(() => cv6Connection.error.value);
const connectCV6 = () => {
cv6Connection.connect();
};
const showSettings = Vue.ref(false);
const toggleShowSettings = () => {
showSettings.value = !showSettings.value;
};
let addChatItemCountMessages = [];
const addChatItemCountInLastMinute = Vue.ref(0);
let addChatItemCountIntervalId;
let addChatItemCountSubscriber;
Vue.onMounted(() => {
addChatItemCountIntervalId = setInterval(() => {
const now = Date.now();
const index = addChatItemCountMessages.findIndex((e) => now - e.timestamp <= 60000);
addChatItemCountMessages =
index >= 0 ? addChatItemCountMessages.slice(index) : [];
addChatItemCountInLastMinute.value = addChatItemCountMessages.reduce((count, e) => count + e.count, 0);
}, 5000);
addChatItemCountSubscriber = liveChatAddChatItemCountMessager.subscribe((message) => {
addChatItemCountMessages.push(message);
});
});
Vue.onUnmounted(() => {
if (addChatItemCountIntervalId) {
clearInterval(addChatItemCountIntervalId);
addChatItemCountIntervalId = undefined;
}
if (addChatItemCountSubscriber) {
addChatItemCountSubscriber();
addChatItemCountSubscriber = undefined;
}
});
return {
rootStyle,
onUserLiveChatRegister,
onUserLiveChatUnregister,
userLiveChatTextFilterPlaceholder,
userLiveChatTextFilterHelp,
onDeletedLiveChatRegister,
onDeletedLiveChatUnregister,
deletedLiveChatTextFilterPlaceholder,
deletedLiveChatTextFilterHelp,
deletedLiveChatTextFilterFn,
addChatItemCountInLastMinute,
connectCV6,
cv6ConnectionStatus,
cv6ConnectionError,
showSettings,
toggleShowSettings,
};
},
template: `
<teleport to="#contents.yt-live-chat-app">
<div :style="rootStyle">
<div style="display: flex; align-items: center; margin-bottom: 5px">
<div>最近一分钟消息数:{{addChatItemCountInLastMinute}}</div>
<div style="display: flex; align-items: center; margin-left: auto">
<span v-if="cv6ConnectionError" :title="cv6ConnectionError">❌</span>
<span v-if="cv6ConnectionStatus === 'open'" title="CV-6已连接">🌐</span>
<button title="连接到CV-6" :disabled="cv6ConnectionStatus === 'open'" @click="connectCV6">📡</button>
<button title="设置" @click="toggleShowSettings">⚙️</button>
</div>
</div>
<div v-show="!showSettings" style="display: flex; flex-direction: column; flex-grow: 1">
<div style="border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin: 0 -5px" />
<app-live-chat-container header="用户消息观测" @register="onUserLiveChatRegister" @unregister="onUserLiveChatUnregister" :textFilterPlaceholder="userLiveChatTextFilterPlaceholder" :textFilterHelp="userLiveChatTextFilterHelp" />
<div style="border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin: 0 -5px" />
<app-live-chat-container header="删除消息观测" @register="onDeletedLiveChatRegister" @unregister="onDeletedLiveChatUnregister" :textFilterPlaceholder="deletedLiveChatTextFilterPlaceholder" :textFilterHelp="deletedLiveChatTextFilterHelp" :textFilterFn="deletedLiveChatTextFilterFn" />
</div>
<app-settings v-if="showSettings" @close="toggleShowSettings" />
</div>
</teleport>
`,
});
app.component("app-live-chat-container", {
props: [
"header",
"textFilterPlaceholder",
"textFilterHelp",
"textFilterFn",
],
emits: ["register", "unregister"],
setup(props, { emit }) {
const authorView = Vue.ref(false);
const toggleAuthorView = () => {
authorView.value = !authorView.value;
};
const deletedTypeFilter = Vue.ref(0);
const toggleDeletedTypeFilter = () => {
switch (deletedTypeFilter.value) {
case 0:
deletedTypeFilter.value = ChatItemDeletedType.ByItem;
break;
case ChatItemDeletedType.ByItem:
deletedTypeFilter.value = ChatItemDeletedType.ByAuthor;
break;
case ChatItemDeletedType.ByAuthor:
deletedTypeFilter.value = 0;
break;
default:
deletedTypeFilter.value = 0;
break;
}
};
const deletedTypeFilterText = Vue.computed(() => "过滤:" +
(deletedTypeFilter.value === ChatItemDeletedType.ByItem
? ChatItemDeletedTypeIcons[ChatItemDeletedType.ByItem]
: deletedTypeFilter.value === ChatItemDeletedType.ByAuthor
? ChatItemDeletedTypeIcons[ChatItemDeletedType.ByAuthor]
: "无"));
const textFilter = Vue.ref("");
const filterRegExp = Vue.computed(() => {
const execArr = /^\/(.+)\/([gimsuy]*)$/.exec(textFilter.value);
if (execArr) {
try {
return new RegExp(execArr[1], execArr[2]);
}
catch (error) {
return null;
}
}
else {
return undefined;
}
});
const filter = Vue.computed(() => {
return filterRegExp.value
? filterRegExp.value
: filterRegExp.value === null
? ""
: textFilter.value;
});
const items = Vue.ref([]);
const filteredItems = Vue.computed(() => {
let result = items.value.slice();
if (props.textFilterFn) {
result = props.textFilterFn(result, filter.value);
}
if (deletedTypeFilter.value) {
const reversedItems = result.reverse();
result = [];
let deletedType;
reversedItems.forEach((item) => {
if (item.deletedEfficiency != null) {
deletedType = item.deletedType;
}
if ((deletedTypeFilter.value === ChatItemDeletedType.ByItem &&
(deletedType !== null && deletedType !== void 0 ? deletedType : 0) & ChatItemDeletedType.ByItem) ||
(deletedTypeFilter.value === ChatItemDeletedType.ByAuthor &&
deletedType === ChatItemDeletedType.ByAuthor)) {
result.push(item);
}
});
result = result.reverse();
}
return result;
});
const filteredAuthorItems = Vue.computed(() => {
const authorMap = new Map();
filteredItems.value
.slice()
.reverse()
.forEach((item) => {
if (!authorMap.has(item.channelId)) {
authorMap.set(item.channelId, Object.assign(Object.assign({}, item), { id: item.channelId, message: "" }));
}
});
return Array.from(authorMap.values()).reverse();
});
const displayedItems = Vue.computed(() => {
return authorView.value
? filteredAuthorItems.value
: filteredItems.value;
});
const clear = () => {
const filteredIdSet = new Set(filteredItems.value.map((item) => item.id));
items.value = items.value.filter((item) => !filteredIdSet.has(item.id));
};
Vue.onMounted(() => {
emit("register", Vue.reactive({
items,
filter,
}));
});
Vue.onUnmounted(() => {
emit("unregister");
});
return {
authorView,
toggleAuthorView,
toggleDeletedTypeFilter,
deletedTypeFilterText,
filterRegExp,
items,
displayedItems,
textFilter,
copyToClipboard,
clear,
};
},
template: `
<div style="display: flex; flexDirection: column; flex: 1 1 0; min-height: 0">
<div style="display: flex">
<div style="font-size: 16px; font-weight: 600">{{header}}</div>
<button @click="toggleDeletedTypeFilter" style="margin-left: auto">{{deletedTypeFilterText}}</button>
<button @click="toggleAuthorView">{{'查看:' + (authorView ? '用户' : '消息')}}</button>
<button @click="copyToClipboard(displayedItems)">复制</button>
<button @click="clear">清空</button>
</div>
<div style="display: flex">
<input v-model="textFilter" :placeholder="textFilterPlaceholder" style="flex-grow: 1" />
<span v-if="filterRegExp" title="正则表达式合法">✔️</span>
<span v-if="filterRegExp === null" title="正则表达式非法">❌</span>
<span :title="textFilterHelp">ℹ️</span>
</div>
<app-chat-item-list :items="displayedItems" />
</div>
`,
});
app.component("app-chat-item-list", {
props: ["items"],
setup() {
const listEl = Vue.ref(null);
let scrollToBottom = false;
Vue.onBeforeUpdate(() => {
scrollToBottom = listEl.value
? Math.abs(listEl.value.scrollHeight -
listEl.value.scrollTop -
listEl.value.clientHeight) < 2
: false;
});
Vue.onUpdated(() => {
if (listEl.value && scrollToBottom) {
scrollToBottom = false;
listEl.value.scrollTop = listEl.value.scrollHeight;
}
});
return {
listEl,
};
},
template: `
<div ref="listEl" style="flex-grow: 1; overflow-x: hidden; overflow-y: auto; word-break: break-all">
<app-chat-item v-for="item in items" :key="item.id" :item="item" />
</div>
`,
});
app.component("app-chat-item", {
props: ["item"],
setup(props) {
const deletedTypeIcon = Vue.computed(() => {
const item = props.item;
return getChatItemDeletedTypeIcon(item);
});
const deletedTypeTitle = Vue.computed(() => {
const item = props.item;
if (!item.deletedType) {
return null;
}
const arr = [];
if (item.deletedType & ChatItemDeletedType.ByItem) {
arr.push("单条消息被删除");
}
if (item.deletedType & ChatItemDeletedType.ByAuthor) {
arr.push("用户所有消息被删除");
}
let str = "";
if (item.deletedEfficiency == null) {
str += "这是被级联删除的历史消息\r\n\r\n";
}
str += "删除原因:\r\n" + arr.join("\r\n");
return str;
});
const deletedEfficiencyIcon = Vue.computed(() => {
const item = props.item;
return getChatItemDeletedEfficiencyIcon(item);
});
const deletedEfficiencyTitle = Vue.computed(() => {
const item = props.item;
let str = "";
if (item.deletedEfficiency != null) {
str += `删除效率:${item.deletedEfficiency}\r\n`;
}
if (item.deletedDuration != null) {
str += `存活时长:${toDurationString(item.deletedDuration)}`;
}
if (item.deletedType === ChatItemDeletedType.ByAuthor &&
item.deletedEfficiency &&
item.deletedEfficiency > 10) {
str += "\r\n\r\n删除效率数值较高,可能未观测到用户的最近消息";
}
return str;
});
const deletedByAuthor = Vue.computed(() => {
var _a;
const item = props.item;
return ((_a = item.deletedType) !== null && _a !== void 0 ? _a : 0) & ChatItemDeletedType.ByAuthor;
});
return {
deletedTypeIcon,
deletedTypeTitle,
deletedEfficiencyIcon,
deletedEfficiencyTitle,
deletedByAuthor,
};
},
template: `
<div>
<span v-if="item.deletedType" :title="deletedTypeTitle">{{deletedTypeIcon}}</span>
<span v-if="item.deletedType" :title="deletedEfficiencyTitle">{{deletedEfficiencyIcon}}</span>
<span v-if="item.deletedType"> </span>
<span v-if="item.deletedState" style="color: #dc3545" :style="{'font-weight': deletedByAuthor ? 600 : null}">{{item.deletedState}} </span>
<span>[{{item.time}}] </span>
<span style="font-weight: 600">{{item.authorName}} </span>
<span>{{item.message}}</span>
</div>
`,
});
app.component("app-settings", {
emits: ["close"],
setup(props, { emit }) {
const settingsInternal = Vue.ref(Object.assign({}, settings.value));
const _saveSettings = () => {
settings.value = Object.assign({}, settingsInternal.value);
GM_setValue(settingsValueKey, JSON.stringify(settings.value));
};
const saveSettings = () => {
_saveSettings();
emit("close");
};
const resetSettings = () => {
settingsInternal.value = Object.assign({}, defaultSettings);
_saveSettings();
};
return {
settings: settingsInternal,
saveSettings,
resetSettings,
};
},
template: `
<div style="display:flex; flex-direction: column; flex-grow: 1">
<div style="font-size: 18px; font-weight: 600; margin: 5px 0">设置</div>
<div style="font-size: 16px; font-weight: 600; margin: 5px 0">观察哨</div>
<div style="display: flex">
<div style="width: 150px">删除消息报告数</div>
<input style="width: 100px" type="number" required min="1" max="20" step="1" v-model.number="settings.reportedChatItemCount" />
</div>
<div style="font-size: 16px; font-weight: 600; margin: 5px 0">CV-6</div>
<div style="display: flex">
<div style="width: 150px">CV-6端口</div>
<input style="width: 100px" type="number" required min="0" max="65535" step="1" v-model.number="settings.cv6Port" />
</div>
<div style="margin: 5px 0">
<button @click="saveSettings">保存</button>
<button @click="resetSettings">重置</button>
</div>
</div>
`,
});
return app;
}
const headerActionButtonsEl = await waitFor(() => { var _a; return (_a = document.querySelector("#action-buttons.yt-live-chat-header-renderer")) !== null && _a !== void 0 ? _a : undefined; });
if (!headerActionButtonsEl) {
console.error("YouTube聊天观察哨初始化失败");
return;
}
try {
const loadedSettings = JSON.parse(GM_getValue(settingsValueKey) || "{}");
Object.keys(defaultSettings).forEach((key) => {
const settingsKey = key;
if (typeof loadedSettings[settingsKey] ===
typeof defaultSettings[settingsKey]) {
settings.value[settingsKey] = loadedSettings[settingsKey];
}
});
}
catch (error) {
console.error(error);
}
const rootEl = document.createElement("div");
headerActionButtonsEl.prepend(rootEl);
const app = createApp();
app.mount(rootEl);
})();