Skip to content

Instantly share code, notes, and snippets.

@kitce
Last active October 30, 2022 16:54
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kitce/c60f6dda93b1dc51d90c8852d7d9f557 to your computer and use it in GitHub Desktop.
Save kitce/c60f6dda93b1dc51d90c8852d7d9f557 to your computer and use it in GitHub Desktop.
LIHKG - Show user information (e.g. user ID, registration time)
// ==UserScript==
// @name LIHKG Show User Info
// @namespace https://gist.github.com/kitce
// @version 0.16.0
// @description Show the user information (e.g. user ID, registration date) in each reply
// @author kitce
// @include https://lihkg.com/*
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
// Constants
const DEBUG = false;
const PLUGIN_NAMESPACE = 'LIHKG-Show-User-Info';
const RENDERED_FLAG = '__rendered';
const WARN_REGISTRATION_TIME_THRESHOLD = 1560038400000; // 2019-06-09;
const REGISTRATION_DATE_PREFIX = '註冊日期: ';
const USER_ID_TOOLTIP_TEXT = '用戶編號 (ID)';
const CURRENT_USERNAME_TOOLTIP_TEXT = '現時用戶名稱';
const REPLY_API_URL_REGEX = /^https:\/\/lihkg\.com\/api_v2\/thread\/(\d+)\/([a-f0-9]+)$/;
const REPLY_LIST_API_URL_REGEX = /^https:\/\/lihkg\.com\/api_v2\/thread\/(\d+)\/page\/\d+/;
const QUOTE_LIST_API_URL_REGEX = /^https:\/\/lihkg\.com\/api_v2\/thread\/(\d+)\/[a-f0-9]+\/quotes\/page\/\d+/;
const ATTRIBUTES = {
dataPostId: 'data-post-id'
};
const SELECTORS = {
page: '._3jxQCFWg9LDtkSkIVLzQ8L',
reply: `[${ATTRIBUTES.dataPostId}]`,
replyBody: '._1Hi94Pa6lplJ436NQaMbEt',
portalBody: '._15Y0ebHstpjSjX2xCZCZ8U',
quotationModalBody: '._3cD6jss2DikeGo9amwpiZm',
message: '._2bokd4pLvU5_-Lc97NVqzn',
nickname: '.ZZtOrmcIRcvdpnW09DzFk'
};
const CSS_CLASSES = {
registrationInfo: `${PLUGIN_NAMESPACE}-registration-info`,
messageInfo: '_1VcuFUmnOEK51TsshmrnJM',
seperator: 'UM_uefRXp7fgzd4Cl8y2A',
staffUsername: '_208tAU6LsyjP5LKTdcPXD0',
maleUsername: 'A0jheqYUBHNW93KykXKEH',
femaleUsername: 'jj_3ZDzjtPixL1b2KTcpS',
highlight: `${PLUGIN_NAMESPACE}-highlight`
};
const PLACEHOLDERS = {
userId: '{USER_ID}',
username: '{USERNAME}',
usernameCssClass: '{USERNAME_CSS_CLASS}',
registrationDate: '{REGISTRATION_DATE}',
registrationDateCssClass: '{REGISTRATION_DATE_CSS_CLASS}',
year: '{YEAR}',
month: '{MONTH}',
day: '{DAY}'
};
// Enums
const PortalStatus = {
Unmounted: 'unmounted',
Entering: 'entering',
Entered: 'entered',
Exited: 'exited'
};
// Variables
const _registrationInfoTemplate = `
<small class="${CSS_CLASSES.messageInfo}">
<span data-tip="${USER_ID_TOOLTIP_TEXT}" title="${USER_ID_TOOLTIP_TEXT}" currentitem="false">
<i class="i-account"></i> #${PLACEHOLDERS.userId}
</span>
<span class="${CSS_CLASSES.seperator}">•</span>
<span data-tip="${CURRENT_USERNAME_TOOLTIP_TEXT}" title="${CURRENT_USERNAME_TOOLTIP_TEXT}" class="${PLACEHOLDERS.usernameCssClass}" currentitem="false">${PLACEHOLDERS.username}</span>
</small>`;
const _registrationDateTemplate = `${PLACEHOLDERS.year}年${PLACEHOLDERS.month}月${PLACEHOLDERS.day}日 HH:mm`;
const _injection = {};
const _cache = { replies: {} };
// Create stylesheet
_createStylesheet(`
.${CSS_CLASSES.registrationInfo} {
margin-bottom: .5rem;
}
.${CSS_CLASSES.highlight} {
border: solid 1px #444443;
border-radius: 4px;
padding: 4px;
}
`);
// Intercept XHR
_interceptXHR('load', function () {
const isReply = REPLY_API_URL_REGEX.test(this.responseURL);
const isReplyList = REPLY_LIST_API_URL_REGEX.test(this.responseURL);
const isQuoteList = QUOTE_LIST_API_URL_REGEX.test(this.responseURL);
if (isReply || isReplyList || isQuoteList) {
const data = JSON.parse(this.responseText);
if (data.success === 1) {
if (isReply) {
_cacheReply(data.response);
}
if (isReplyList) {
_cacheReplies(data.response);
}
if (isQuoteList) {
_cacheReplies(data.response);
}
}
}
});
function renderPage (node) {
const replyNodes = node.querySelectorAll(SELECTORS.reply);
for (const node of replyNodes) {
renderReply(node);
}
}
function renderReply (node) {
if (node && !node[RENDERED_FLAG]) {
const postId = node.getAttribute(ATTRIBUTES.dataPostId);
const reply = _cache.replies[postId];
if (reply) {
const { user } = reply;
const registrationInfo = createRegistrationInfo(user);
const messageInfoNode = node.querySelector(`.${CSS_CLASSES.messageInfo}`);
node.insertBefore(registrationInfo, node.firstChild);
node[RENDERED_FLAG] = true;
}
}
}
function unrenderReply (node) {
const registrationInfo = node.querySelector(`.${CSS_CLASSES.registrationInfo}`);
registrationInfo.remove();
delete node[RENDERED_FLAG];
}
function injectPageComponent (node) {
const component = _findReactComponent(node);
if (component) {
return _interceptReact(component, 'componentDidUpdate', function () {
requestAnimationFrame(() => {
const { _ref: pageElement } = this;
if (pageElement && _isPageNode(pageElement)) {
renderPage(pageElement);
}
})
});
}
}
function injectPortalBodyComponent (node) {
const component = _findReactComponent(node, 1);
if (component) {
return _interceptReact(component, 'componentDidUpdate', function () {
requestAnimationFrame(() => {
const { state } = this;
const { stateNode } = this._reactInternalFiber.child?.lastEffect?.return?.return || {};
if (state.status === PortalStatus.Entered && stateNode && _isQuotationModalBody(stateNode)) {
renderReply(stateNode.firstChild.firstChild);
}
});
});
}
}
function createRegistrationInfo (user) {
const registrationInfo = document.createElement('div');
registrationInfo.classList.add(CSS_CLASSES.registrationInfo);
const usernameCssClass = user.level >= 999 ? CSS_CLASSES.staffUsername : user.gender === 'M' ? CSS_CLASSES.maleUsername : CSS_CLASSES.femaleUsername;
const registrationTime = _getRegistrationTime(user);
const registrationDateCssClass = _isAboveWarnRegistrationTimeThreshold(user) ? CSS_CLASSES.highlight : '';
registrationInfo.innerHTML = _registrationInfoTemplate.replace(PLACEHOLDERS.userId, user.user_id)
.replace(PLACEHOLDERS.username, user.nickname)
.replace(PLACEHOLDERS.usernameCssClass, usernameCssClass)
.replace(PLACEHOLDERS.registrationDate, _formatDate(registrationTime))
.replace(PLACEHOLDERS.registrationDateCssClass, registrationDateCssClass);
return registrationInfo;
}
// Helper functions
function _createStylesheet (stylesheet) {
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.innerHTML = stylesheet;
document.head.appendChild(style);
}
function _formatDate (date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
return _registrationDateTemplate.replace(PLACEHOLDERS.year, year)
.replace(PLACEHOLDERS.month, month)
.replace(PLACEHOLDERS.day, day)
.replace('HH', hours < 10 ? `0${hours}` : hours)
.replace('mm', minutes < 10 ? `0${minutes}` : minutes);
}
function _interceptXHR (event, handler) {
const _open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function () {
this.addEventListener(event, handler.bind(this));
return _open.apply(this, arguments);
};
}
function _interceptReact (component, method, injection) {
const { constructor: Component } = component;
const _method = Component.prototype[method];
Component.prototype[method] = function () {
injection.apply(this, arguments);
return _method.apply(this, arguments);
};
return Component;
}
function _findReactComponent (element, traverseUp = 0) {
const key = Object.keys(element).find((key) => key.startsWith('__reactInternalInstance$'));
const fiber = element[key];
if (!fiber) return null;
// react <16
if (fiber._currentElement) {
let compFiber = fiber._currentElement._owner;
for (let i = 0; i < traverseUp; i++) {
compFiber = compFiber._currentElement._owner;
}
return compFiber._instance;
}
// react 16+
const getComponentFiber = (fiber) => {
// return fiber._debugOwner; // this also works, but is __DEV__ only
let parentFiber = fiber.return;
while (typeof parentFiber.type === 'string') {
parentFiber = parentFiber.return;
}
return parentFiber;
};
let compFiber = getComponentFiber(fiber);
for (let i = 0; i < traverseUp; i++) {
compFiber = getComponentFiber(compFiber);
}
return compFiber.stateNode;
}
function _cacheReply (response) {
const { post } = response;
_cachePost(post, true);
}
function _cacheReplies (response) {
const { item_data: items } = response;
for (const item of items) {
_cachePost(item);
}
}
function _cachePost (item, overwrite = false) {
const { post_id: postId, quote } = item;
_cache.replies[postId] = overwrite ? item : (_cache.replies[postId] || item);
if (quote) {
_cachePost(quote, overwrite);
}
}
function _getReply (postId) {
return _cache.replies[postId];
}
function _getRegistrationTime (user) {
return new Date(user.create_time * 1000);
}
function _isAboveWarnRegistrationTimeThreshold (user) {
const registrationTime = _getRegistrationTime(user);
const warnRegistrationTimeThreshold = new Date(WARN_REGISTRATION_TIME_THRESHOLD);
return registrationTime.getTime() >= warnRegistrationTimeThreshold.getTime();
}
function _isPageNode (node) {
return node.matches(SELECTORS.page);
}
function _isReplyNode (node) {
return node.matches(SELECTORS.reply);
}
function _isReplyBodyNode (node) {
return node.matches(SELECTORS.replyBody);
}
function _isPortalBodyNode (node) {
return node.matches(SELECTORS.portalBody);
}
function _isQuotationModalBody (node) {
return node.matches(SELECTORS.quotationModalBody);
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (DEBUG) {
const { type, target, addedNodes, removedNodes } = mutation;
console.info(type, target, addedNodes, removedNodes);
console.info('==========================================================');
}
if (!_injection.PageComponent || !_injection.PortalBodyComponent) {
switch (mutation.type) {
case 'childList': {
for (const node of mutation.addedNodes) {
if (node.nodeType === document.ELEMENT_NODE) {
if (!_injection.PageComponent && _isPageNode(node)) {
_injection.PageComponent = injectPageComponent(node);
}
if (!_injection.PortalBodyComponent && _isPortalBodyNode(node)) {
// quotation modal
_injection.PortalBodyComponent = injectPortalBodyComponent(node);
}
}
}
}
}
}
requestAnimationFrame(() => {
switch (mutation.type) {
case 'childList': {
for (const node of mutation.addedNodes) {
if (node.nodeType === document.ELEMENT_NODE) {
if (_isReplyNode(node)) {
renderReply(node);
}
if (_isReplyBodyNode(node)) {
renderReply(node.parentNode);
}
}
}
break;
}
case 'attributes': {
if (mutation.attributeName === ATTRIBUTES.dataPostId) {
const { target } = mutation;
unrenderReply(target);
renderReply(target);
}
break;
}
}
});
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: [ATTRIBUTES.dataPostId]
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment