Last active
October 30, 2022 16:54
-
-
Save kitce/c60f6dda93b1dc51d90c8852d7d9f557 to your computer and use it in GitHub Desktop.
LIHKG - Show user information (e.g. user ID, registration time)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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