├── .eslintignore ├── assets └── images │ └── main │ ├── icon.png │ ├── icon16.png │ ├── icon32.png │ ├── icon64.png │ ├── favicon.ico │ └── favicon32.ico ├── src ├── vite-env.d.ts ├── stylesheets │ ├── main │ │ ├── variables.scss │ │ ├── material-symbols.css │ │ ├── base.scss │ │ ├── animations.scss │ │ ├── palette.scss │ │ └── universal.scss │ ├── tieba │ │ ├── tieba-error.scss │ │ └── tieba-main.scss │ ├── components │ │ ├── user-textbox.scss │ │ └── user-button.scss │ └── modules │ │ ├── animation-exports.scss │ │ └── common.scss ├── ex.d.ts ├── components │ ├── images-viewer │ │ └── index.tsx │ ├── block-panel.vue │ ├── header-progress.vue │ ├── color-picker.vue │ ├── toggle-panel.vue │ ├── await-dialog.vue │ ├── dropdown-menu.vue │ ├── feeds-masonry.vue │ ├── pager.vue │ └── post-container.vue ├── lib │ ├── theme │ │ ├── page-extension │ │ │ ├── thread │ │ │ │ ├── compact.scss │ │ │ │ ├── parser.ts │ │ │ │ └── comments.scss │ │ │ └── index │ │ │ │ └── index.ts │ │ └── index.ts │ ├── tieba-components │ │ ├── forum.ts │ │ ├── nav-bar.ts │ │ ├── pager.ts │ │ ├── float-bar.tsx │ │ └── thread.ts │ ├── api │ │ ├── abstract.ts │ │ └── remixed.tsx │ ├── common │ │ ├── index.ts │ │ ├── settings │ │ │ └── setting-widgets │ │ │ │ ├── theme.color.vue │ │ │ │ ├── layout.custom-back.vue │ │ │ │ ├── about.detail.vue │ │ │ │ └── about.update.vue │ │ └── packer.ts │ ├── utils │ │ ├── queue.ts │ │ ├── frame-interval.ts │ │ ├── color.ts │ │ └── index.ts │ ├── render │ │ ├── universal.tsx │ │ ├── jsx-extension.tsx │ │ ├── layout │ │ │ └── float.ts │ │ └── index.tsx │ ├── elemental │ │ ├── event-proxy.ts │ │ ├── styles.ts │ │ └── index.ts │ ├── perf.ts │ └── observers.ts ├── tieba.d.ts ├── modules │ ├── no-login │ │ └── index.ts │ ├── remixed-theme │ │ ├── tieba-components │ │ │ ├── nav-bar.tsx │ │ │ ├── float-bar.scss │ │ │ └── nav-bar.scss │ │ └── index.ts │ ├── easy-jump │ │ └── index.ts │ ├── tieba-tags │ │ ├── stylesheet.css │ │ └── index.ts │ ├── shield │ │ ├── index.ts │ │ ├── shield-editor.vue │ │ ├── shield.ts │ │ └── module.shield.vue │ ├── portal │ │ └── index.ts │ ├── toolkit │ │ └── index.ts │ └── notrans-emojis │ │ └── index.ts ├── main.ts └── global.d.ts ├── .gitignore ├── tsconfig.json ├── .stylelintrc.cjs ├── LICENSE ├── README.md ├── package.json ├── .eslintrc.cjs └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | tieba-remix.user.js 2 | .eslintrc.js -------------------------------------------------------------------------------- /assets/images/main/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/HEAD/assets/images/main/icon.png -------------------------------------------------------------------------------- /assets/images/main/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/HEAD/assets/images/main/icon16.png -------------------------------------------------------------------------------- /assets/images/main/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/HEAD/assets/images/main/icon32.png -------------------------------------------------------------------------------- /assets/images/main/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/HEAD/assets/images/main/icon64.png -------------------------------------------------------------------------------- /assets/images/main/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/HEAD/assets/images/main/favicon.ico -------------------------------------------------------------------------------- /assets/images/main/favicon32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/HEAD/assets/images/main/favicon32.ico -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | //// 4 | -------------------------------------------------------------------------------- /src/stylesheets/main/variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --xfast-duration: 0.1s; 3 | --fast-duration: 0.2s; 4 | --default-duration: 0.4s; 5 | --slow-duration: 0.6s; 6 | --xslow-duration: 0.8s; 7 | } 8 | -------------------------------------------------------------------------------- /src/ex.d.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "vue"; 2 | import { JSX } from "vue/jsx-runtime"; 3 | import { SubSettingKey } from "./components/settings.vue"; 4 | 5 | /** 用户模块(扩展) */ 6 | interface UserModuleEx extends UserModule { 7 | settings?: SubSettingKey["content"] 8 | } 9 | 10 | /** 支持的组件类型 */ 11 | type SupportedComponent = Component | JSX.Element; 12 | -------------------------------------------------------------------------------- /src/components/images-viewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { renderDialog } from "@/lib/render"; 2 | import ImagesViewer, { ImagesViewerOpts } from "./images-viewer.vue"; 3 | 4 | export default ImagesViewer; 5 | export * from "./images-viewer.vue"; 6 | 7 | export function imagesViewer(opts: ImagesViewerOpts) { 8 | renderDialog(); 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/theme/page-extension/thread/compact.scss: -------------------------------------------------------------------------------- 1 | body[compact-layout] { 2 | #j_p_postlist { 3 | gap: 0; 4 | } 5 | 6 | .core_reply_content li.first_no_border { 7 | margin-top: -4px; 8 | } 9 | 10 | // 楼中楼单个回复 11 | .core_reply .core_reply_wrapper .core_reply_content .lzl_single_post { 12 | margin-bottom: 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/tieba-components/forum.ts: -------------------------------------------------------------------------------- 1 | export interface ForumInfo { 2 | name: string; 3 | // followersDisplay: string; 4 | // postsDisplay: string; 5 | } 6 | 7 | export interface ForumComponents { 8 | nameAnchor: HTMLAnchorElement; 9 | iconContainer: HTMLAnchorElement; 10 | followButton: HTMLAnchorElement; 11 | signButton: HTMLAnchorElement; 12 | } 13 | 14 | export interface TiebaForum { 15 | info: ForumInfo; 16 | components: ForumComponents; 17 | } 18 | -------------------------------------------------------------------------------- /src/stylesheets/tieba/tieba-error.scss: -------------------------------------------------------------------------------- 1 | /* 搜索栏 */ 2 | .search-form { 3 | background-color: var(--default-background); 4 | } 5 | 6 | .search-form p { 7 | display: none; 8 | } 9 | 10 | .page404 { 11 | background-color: var(--default-background); 12 | } 13 | 14 | .main-title { 15 | color: var(--default-fore); 16 | } 17 | 18 | .main-title a { 19 | color: var(--tieba-theme-fore); 20 | } 21 | 22 | .app_download_box { 23 | display: none; 24 | } 25 | 26 | #error_404_iframe { 27 | display: none; 28 | } -------------------------------------------------------------------------------- /src/tieba.d.ts: -------------------------------------------------------------------------------- 1 | const Env: { 2 | server_time: number 3 | }; 4 | 5 | const datalazyload: { 6 | userConfig: { 7 | diff: number; 8 | imgOnloadCallback: object; 9 | subListLoadCallback: object; 10 | placeholder: string; 11 | execScript: boolean; 12 | container: object; 13 | autoDestory: boolean; 14 | } 15 | 16 | _callbacks: object; 17 | _containerIsNotDocument: boolean; 18 | _images: HTMLImageElement[]; 19 | _subList: HTMLDivElement[]; 20 | _textareas: []; 21 | _codes: []; 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | archived/ 27 | backup/ 28 | debug/ 29 | docs/patch-notes/ 30 | docs/README.lite.md 31 | 32 | @deprecated* 33 | @debug* 34 | deb.ts 35 | index.html 36 | todo.md 37 | auto-imports.d.ts 38 | components.d.ts 39 | -------------------------------------------------------------------------------- /src/lib/tieba-components/nav-bar.ts: -------------------------------------------------------------------------------- 1 | import { TiebaComponent } from "../api/abstract"; 2 | import { dom } from "../elemental"; 3 | 4 | export class NavBar extends TiebaComponent<"div"> { 5 | public leftContainer() { 6 | return dom(".left-container", this.get(), [])[0]; 7 | } 8 | 9 | public middleContainer() { 10 | return dom(".middle-container", this.get(), [])[0]; 11 | } 12 | 13 | public rightContainer() { 14 | return dom(".right-container", this.get(), [])[0]; 15 | } 16 | } 17 | 18 | export const navBar = new NavBar("#nav-bar"); 19 | -------------------------------------------------------------------------------- /src/lib/api/abstract.ts: -------------------------------------------------------------------------------- 1 | import { dom } from "@/lib/elemental"; 2 | 3 | export interface TiebaAbstract { 4 | el: HTMLElement; 5 | } 6 | 7 | export class TiebaComponent { 8 | private selector: string; 9 | private parent?: Element; 10 | 11 | constructor(selector: string, parent?: Element) { 12 | this.selector = selector; 13 | this.parent = parent; 14 | } 15 | 16 | public get() { 17 | if (!this.parent) { 18 | return dom(this.selector, [])[0]; 19 | } 20 | return dom(this.selector, this.parent, [])[0]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/common/index.ts: -------------------------------------------------------------------------------- 1 | import { customRef } from "vue"; 2 | 3 | /** 4 | * 创建延迟更新(防抖)的 `ref` 5 | * @param value 初始化数据 6 | * @param delay 延迟时间 7 | * @returns 具有防抖效果的 `ref` 8 | */ 9 | export function delayedRef(value: T, delay = 500) { 10 | let timeout: number | undefined; 11 | return customRef((track, trigger) => ({ 12 | get() { 13 | track(); 14 | return value; 15 | }, 16 | set(newValue) { 17 | clearTimeout(timeout); 18 | timeout = setTimeout(() => { 19 | trigger(); 20 | value = newValue; 21 | }, delay); 22 | }, 23 | })); 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/no-login/index.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | id: "nologin-tieba", 3 | name: "免登录浏览", 4 | author: "锯条", 5 | version: "1.0", 6 | brief: "免登录浏览贴吧", 7 | description: `始终伪装为已登录状态,让免登录浏览和已登录基本一致`, 8 | scope: ["thread"], 9 | runAt: "DOMLoaded", 10 | entry: main, 11 | } as UserModule; 12 | 13 | function main() { 14 | if (PageData.user.is_login) return; 15 | 16 | PageData.user.is_login = 1; 17 | // const nameValue = document.createElement("div"); 18 | // nameValue.id = "nameValue"; 19 | 20 | // document.addEventListener("DOMContentLoaded", () => { 21 | // document.body.appendChild(nameValue); 22 | // }); 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/remixed-theme/tieba-components/nav-bar.tsx: -------------------------------------------------------------------------------- 1 | import navBarVue from "@/components/nav-bar.vue"; 2 | import { asyncdom } from "@/lib/elemental"; 3 | import { renderComponent } from "@/lib/render"; 4 | import { insertJSX } from "@/lib/render/jsx-extension"; 5 | 6 | import { GM_addStyle } from "$"; 7 | import navBarCSS from "./nav-bar.scss?inline"; 8 | 9 | export default async function () { 10 | GM_addStyle(navBarCSS); 11 | 12 | const elder = await asyncdom("#com_userbar"); 13 | const navWrapper = ; 14 | insertJSX(navWrapper, document.body, elder); 15 | renderComponent(navBarVue, await asyncdom("#nav-wrapper")); 16 | } 17 | -------------------------------------------------------------------------------- /src/stylesheets/components/user-textbox.scss: -------------------------------------------------------------------------------- 1 | %user-textbox { 2 | box-sizing: border-box; 3 | padding: 4px; 4 | border: 2px solid var(--border-color); 5 | border-radius: 6px; 6 | background-color: var(--default-background); 7 | outline: none; 8 | transition: all var(--default-duration), width 0s, height 0s; 9 | 10 | &:hover { 11 | border-color: var(--light-background); 12 | } 13 | 14 | &:focus { 15 | border-color: var(--tieba-theme-color); 16 | } 17 | 18 | &.lodash-style { 19 | padding: 0; 20 | border: none; 21 | border-radius: 0; 22 | border-bottom: 2px solid var(--border-color); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/stylesheets/modules/animation-exports.scss: -------------------------------------------------------------------------------- 1 | @mixin fade-in($time: var(--default-duration)) { 2 | animation: kf-fade-in $time; 3 | } 4 | 5 | @mixin fade-out($time: var(--default-duration)) { 6 | animation: kf-fade-out $time; 7 | } 8 | 9 | @mixin dialog-in($time: var(--default-duration)) { 10 | animation: kf-dialog-in $time; 11 | } 12 | 13 | @mixin dialog-out($time: var(--default-duration)) { 14 | animation: kf-dialog-out $time; 15 | } 16 | 17 | @mixin bounce-zoom-in($duration: var(--default-duration)) { 18 | animation: kf-zoom-in $duration cubic-bezier(0.18, 0.89, 0.32, 1.2); 19 | } 20 | 21 | @mixin fade-zoom-in($duration: var(--default-duration)) { 22 | animation: kf-fade-zoom-in $duration; 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/theme/page-extension/index/index.ts: -------------------------------------------------------------------------------- 1 | import { GM_addStyle } from "$"; 2 | import { currentPageType } from "@/lib/api/remixed"; 3 | import { asyncdom } from "@/lib/elemental"; 4 | import { parseMultiCSS } from "@/lib/elemental/styles"; 5 | import { renderPage } from "@/lib/render"; 6 | import { pageExtension } from "@/lib/user-values"; 7 | import Home from "./index.vue"; 8 | 9 | export default async function () { 10 | if (currentPageType() !== "index") return; 11 | if (!pageExtension.get().index) return; 12 | 13 | const bodyMask = GM_addStyle(parseMultiCSS({ 14 | "body": { 15 | display: "none", 16 | }, 17 | })); 18 | 19 | const wrap = await asyncdom(".wrap1"); 20 | renderPage(Home); 21 | wrap.remove(); 22 | bodyMask.remove(); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/utils/queue.ts: -------------------------------------------------------------------------------- 1 | export class Queue { 2 | constructor(items?: Array) { 3 | if (items) this.items = items; 4 | } 5 | 6 | private items: T[] = []; 7 | 8 | /** 队首 */ 9 | peek() { 10 | return this.items[0] ? this.items[0] : undefined; 11 | } 12 | 13 | /** 队列长度 */ 14 | length() { 15 | return this.items.length; 16 | } 17 | 18 | /** 入队 */ 19 | enqueue(...elements: T[]): void { 20 | this.items.push(...elements); 21 | } 22 | 23 | /** 出队 */ 24 | dequeue(): T | undefined { 25 | return this.items.shift(); 26 | } 27 | 28 | /** 队列是否为空 */ 29 | isEmpty(): boolean { 30 | return this.items.length === 0; 31 | } 32 | 33 | /** 清空队列 */ 34 | clear(): void { 35 | this.items = []; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/render/universal.tsx: -------------------------------------------------------------------------------- 1 | import HeaderProgress, { HeaderProgressProps } from "@/components/header-progress.vue"; 2 | import { waitUntil } from "../utils"; 3 | import { insertJSX } from "./jsx-extension"; 4 | 5 | export function headerProgress(props: HeaderProgressProps, delay = 2000, timeout = 10000) { 6 | const progressBar = ; 7 | const rendered = insertJSX(progressBar, document.body, document.body.firstChild ?? undefined); 8 | const timeoutTimer = setTimeout(() => { 9 | rendered.root.remove(); 10 | }, timeout); 11 | waitUntil(() => rendered.root.style.width === "100vw", timeout).then(function () { 12 | setTimeout(() => { 13 | rendered.root.remove(); 14 | clearTimeout(timeoutTimer); 15 | }, delay); 16 | }); 17 | return rendered; 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/easy-jump/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Easy Jump 3 | * 直接获取贴吧中超链接的直链,不再进行中转 4 | * @HacksawBlade 5 | */ 6 | 7 | import { afterHead, asyncdom } from "@/lib/elemental"; 8 | import { injectCSSRule } from "@/lib/elemental/styles"; 9 | 10 | export default { 11 | id: "easy-jump", 12 | name: "直链跳转", 13 | author: "锯条", 14 | version: "1.0.2", 15 | brief: "链接跳转避免二次确认", 16 | description: `自动跳转至分享链接的原始地址,不再进行中转(不处理被严重警告的链接)`, 17 | scope: /jump2?.bdimg.com\/safecheck\//, 18 | runAt: "immediately", 19 | entry: main, 20 | } as UserModule; 21 | 22 | async function main() { 23 | afterHead(function () { 24 | injectCSSRule("html", { 25 | backgroundColor: "var(--page-background)", 26 | }); 27 | injectCSSRule("body", { 28 | display: "none", 29 | }); 30 | }); 31 | 32 | location.href = (await asyncdom<"a">(".link")).innerText; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/block-panel.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 40 | -------------------------------------------------------------------------------- /src/stylesheets/main/material-symbols.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable font-family-no-missing-generic-family-keyword */ 2 | /* https: //fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,200..400,0..1,-50..100 */ 3 | 4 | /* fallback */ 5 | @font-face { 6 | font-family: "Material Symbols"; 7 | font-style: normal; 8 | font-weight: 200 400; 9 | src: url("https://fonts.gstatic.com/s/materialsymbolsoutlined/v110/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2") format("woff2"); 10 | } 11 | 12 | .material-symbols-outlined { 13 | display: inline-block; 14 | direction: ltr; 15 | font-family: "Material Symbols"; 16 | font-size: 24px; 17 | -webkit-font-smoothing: antialiased; 18 | font-style: normal; 19 | font-weight: normal; 20 | letter-spacing: normal; 21 | line-height: 1; 22 | text-transform: none; 23 | white-space: nowrap; 24 | word-wrap: normal; 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "jsxFactory": "h", 10 | "jsxFragmentFactory": "Fragment", 11 | "jsxImportSource": "vue", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "lib": [ 16 | "ESNext", 17 | "DOM", 18 | "DOM.Iterable" 19 | ], 20 | "skipLibCheck": true, 21 | "alwaysStrict": true, 22 | "types": [ 23 | "vite/client", 24 | ], 25 | "noEmit": true, 26 | "baseUrl": "src", 27 | "paths": { 28 | "@/*": [ 29 | "./*" 30 | ] 31 | } 32 | }, 33 | "include": [ 34 | "./src" 35 | ], 36 | } 37 | -------------------------------------------------------------------------------- /.stylelintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "stylelint-config-standard", 4 | "stylelint-config-idiomatic-order", 5 | "stylelint-config-recommended-vue", 6 | "stylelint-config-standard-scss" 7 | ], 8 | plugins: [ 9 | "stylelint-order" 10 | ], 11 | overrides: [ 12 | { 13 | files: ["**/*.{html,vue}"], 14 | customSyntax: "postcss-html" 15 | } 16 | ], 17 | rules: { 18 | "comment-empty-line-before": null, 19 | "selector-class-pattern": null, 20 | "selector-id-pattern": null, 21 | "no-descending-specificity": null, 22 | "declaration-empty-line-before": null, 23 | "custom-property-empty-line-before": null, 24 | "scss/dollar-variable-pattern": null, 25 | "scss/dollar-variable-empty-line-before": null, 26 | "no-empty-source": null, 27 | "scss/double-slash-comment-empty-line-before": null 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/elemental/event-proxy.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | /** 原生事件的代理,用于方便地注册和销毁事件。 */ 4 | export class EventProxy { 5 | private records: EventRecord[] = []; 6 | 7 | /** 8 | * 注册事件 9 | * @param target 事件目标 10 | * @param type 事件类型 11 | * @param callback 事件回调函数 12 | * @param options 选项 13 | */ 14 | public on( 15 | target: Maybe, 16 | type: string, 17 | callback: ((e: E) => void) | EventListenerObject, 18 | options?: AddEventListenerOptions | boolean, 19 | ) { 20 | if (!target) return; 21 | target.addEventListener(type, callback as EventListener, options); 22 | this.records.push({ target, type, callback, options }); 23 | } 24 | 25 | /** 销毁通过该代理注册的所有事件 */ 26 | public release() { 27 | _.forEach(this.records, ({ target, type, callback, options }) => { 28 | target.removeEventListener(type, callback, options); 29 | }); 30 | this.records = []; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 锯条 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/stylesheets/main/base.scss: -------------------------------------------------------------------------------- 1 | html { 2 | padding: 0; 3 | margin: 0; 4 | text-align: justify; 5 | } 6 | 7 | body { 8 | overflow: hidden scroll; 9 | padding: 0; 10 | margin: 0; 11 | font-family: var(--code-zh); 12 | font-weight: var(--font-weight-normal); 13 | 14 | &[no-scrollbar] { 15 | overflow: hidden; 16 | padding-right: var(--scrollbar-width) !important; 17 | } 18 | } 19 | 20 | div, 21 | p { 22 | margin: 0; 23 | } 24 | 25 | h1, 26 | h2, 27 | h3, 28 | h4, 29 | h5, 30 | h6 { 31 | font-weight: var(--font-weight-bold); 32 | } 33 | 34 | select { 35 | padding: 1px 8px; 36 | border: 1px solid var(--border-color); 37 | border-radius: 8px; 38 | cursor: pointer; 39 | } 40 | 41 | option { 42 | cursor: pointer; 43 | } 44 | 45 | option:checked { 46 | background-color: var(--tieba-theme-color); 47 | color: var(--default-background); 48 | } 49 | 50 | a { 51 | color: unset; 52 | text-decoration: none; 53 | word-break: break-all; 54 | } 55 | 56 | .dialogJ { 57 | position: fixed !important; 58 | top: 50% !important; 59 | left: 50% !important; 60 | transform: translate(-50%, -50%); 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/utils/frame-interval.ts: -------------------------------------------------------------------------------- 1 | type Callback = () => void; 2 | type Condition = () => boolean; 3 | 4 | export class FrameInterval { 5 | private id: Maybe; 6 | private callback: Callback; 7 | private thenfn: Callback = () => undefined; 8 | private stopCondition: Condition; 9 | 10 | constructor(callback?: Callback) { 11 | this.callback = callback ?? (() => undefined); 12 | this.stopCondition = () => false; 13 | 14 | this.id = requestAnimationFrame(this.tick.bind(this)); 15 | } 16 | 17 | private tick(): void { 18 | if (this.stopCondition()) { 19 | this.cancel(); 20 | return; 21 | } 22 | 23 | this.callback(); 24 | this.id = requestAnimationFrame(this.tick.bind(this)); 25 | } 26 | 27 | public cancel(): void { 28 | if (this.id) { 29 | cancelAnimationFrame(this.id); 30 | this.id = undefined; 31 | } 32 | this.thenfn(); 33 | } 34 | 35 | public until(stopCondition: Condition) { 36 | this.stopCondition = stopCondition; 37 | return this; 38 | } 39 | 40 | public then(thenfn: Callback) { 41 | this.thenfn = thenfn; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/header-progress.vue: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 31 | 32 | 47 | -------------------------------------------------------------------------------- /src/lib/render/jsx-extension.tsx: -------------------------------------------------------------------------------- 1 | import { VNode, render } from "vue"; 2 | import { JSX } from "vue/jsx-runtime"; 3 | import { domrd } from "../elemental"; 4 | 5 | export interface RenderedJSX { 6 | root: T; 7 | vnode: VNode; 8 | remove(): void; 9 | } 10 | 11 | export function renderJSX(jsxel: JSX.Element, parent: Element): RenderedJSX { 12 | render(jsxel, parent); 13 | const root = parent.firstChild as T; 14 | return { 15 | root, 16 | vnode: jsxel, 17 | remove() { 18 | render(null, parent); 19 | if (root.parentNode) root.remove(); 20 | }, 21 | }; 22 | } 23 | 24 | function createJSXWrapper() { 25 | return domrd("div", { class: "jsx-wrapper" }); 26 | } 27 | 28 | export function insertJSX(jsxel: JSX.Element, parent: Element, position?: Node) { 29 | const jsxWrapper = createJSXWrapper(); 30 | return renderJSX(jsxel, parent.insertBefore(jsxWrapper, position ?? null)); 31 | } 32 | 33 | export function appendJSX(jsxel: JSX.Element, parent: Element) { 34 | const jsxWrapper = createJSXWrapper(); 35 | return renderJSX(jsxel, parent.appendChild(jsxWrapper)); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/color-picker.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ text }} 5 | 6 | 7 | 8 | 38 | 39 | 53 | -------------------------------------------------------------------------------- /src/modules/tieba-tags/stylesheet.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --myself-theme-background: rgb(25 110 153 / 20%); 3 | --myself-theme-fore: rgb(16 73 101); 4 | --cengzhu-theme-background: rgb(255 89 107 / 20%); 5 | --cengzhu-theme-fore: rgb(178 62 90); 6 | } 7 | 8 | @media (prefers-color-scheme: dark) { 9 | :root { 10 | --myself-theme-background: rgb(34 135 204 / 20%); 11 | --myself-theme-fore: rgb(40 160 242); 12 | --cengzhu-theme-background: rgb(204 71 103 / 20%); 13 | --cengzhu-theme-fore: rgb(255 89 118); 14 | } 15 | } 16 | 17 | .tag-elem { 18 | display: inline-block; 19 | } 20 | 21 | .tag-elem::after { 22 | padding: 2px 6px; 23 | border-radius: 4px; 24 | margin: 0 4px; 25 | background-color: var(--trans-light-background); 26 | color: var(--light-fore); 27 | font-size: 12px; 28 | font-weight: var(--font-weight-normal); 29 | } 30 | 31 | .tieba-tags-me::after { 32 | /* background-color: var(--myself-theme-background); 33 | color: var(--myself-theme-fore); */ 34 | content: "我"; 35 | } 36 | 37 | .tieba-tags-lz::after { 38 | /* background-color: var(--tieba-theme-background); 39 | color: var(--tieba-theme-fore); */ 40 | content: "楼主"; 41 | } 42 | 43 | .tieba-tags-cz::after { 44 | /* background-color: var(--cengzhu-theme-background); 45 | color: var(--cengzhu-theme-fore); */ 46 | content: "层主"; 47 | } 48 | -------------------------------------------------------------------------------- /src/stylesheets/components/user-button.scss: -------------------------------------------------------------------------------- 1 | %user-button { 2 | box-sizing: border-box; 3 | padding: 4px 12px; 4 | border: none; 5 | border-radius: 6px; 6 | background: none; 7 | background-color: var(--default-background); 8 | box-shadow: 0 0 0 1px var(--border-color); 9 | color: var(--default-fore); 10 | cursor: pointer; 11 | transition: var(--default-duration); 12 | 13 | &:hover:not([disabled]) { 14 | background-color: var(--default-hover); 15 | } 16 | 17 | &:active:not([disabled]) { 18 | background-color: var(--default-active); 19 | } 20 | 21 | &:focus:not([disabled]) { 22 | border-color: var(--tieba-theme-color); 23 | box-shadow: 0 0 0 2px var(--tieba-theme-color); 24 | } 25 | 26 | &.theme-style { 27 | background-color: var(--tieba-theme-color); 28 | color: var(--default-background) !important; 29 | 30 | &:hover { 31 | background-color: var(--tieba-theme-hover); 32 | } 33 | 34 | &:active { 35 | background-color: var(--tieba-theme-active); 36 | } 37 | } 38 | 39 | &.unset-background { 40 | background-color: unset; 41 | } 42 | 43 | &.no-border { 44 | box-shadow: none; 45 | } 46 | 47 | &.no-border-all { 48 | box-shadow: none; 49 | 50 | &:hover, 51 | &:focus { 52 | box-shadow: none; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/common/settings/setting-widgets/theme.color.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 重置 6 | 7 | 8 | 9 | 31 | 32 | 45 | 46 | 51 | -------------------------------------------------------------------------------- /src/modules/remixed-theme/tieba-components/float-bar.scss: -------------------------------------------------------------------------------- 1 | $float-bar-width: 40px; 2 | $float-bar-layout-margin: 20px; 3 | $float-bar-default-left: calc(50% + var(--content-max) / 2 + $float-bar-layout-margin); 4 | 5 | .tbui_aside_float_bar { 6 | bottom: $float-bar-layout-margin; 7 | left: $float-bar-default-left; 8 | display: flex; 9 | overflow: hidden; 10 | width: max-content; 11 | flex-direction: column; 12 | border-radius: 8px; 13 | margin-left: 0; 14 | background-color: var(--very-light-background) !important; 15 | gap: 4px; 16 | 17 | @include main-box-shadow; 18 | 19 | [no-scrollbar] & { 20 | left: calc($float-bar-default-left - var(--scrollbar-width) / 2); 21 | } 22 | 23 | .shrink-view & { 24 | bottom: 0; 25 | left: calc(100% - $float-bar-width); 26 | } 27 | 28 | [no-scrollbar].shrink-view & { 29 | left: calc(100% - $float-bar-width - var(--scrollbar-width)); 30 | } 31 | 32 | .tbui_aside_fbar_button { 33 | border-radius: 0; 34 | margin: 0 !important; 35 | background-color: var(--default-background); 36 | transition: var(--default-duration); 37 | 38 | a { 39 | border-radius: 0; 40 | 41 | &:hover { 42 | color: var(--tieba-theme-color); 43 | } 44 | 45 | &:active { 46 | color: var(--tieba-theme-fore); 47 | } 48 | } 49 | 50 | &[style*="visibility: hidden"] { 51 | height: 0; 52 | margin-top: -4px !important; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/perf.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { currentPageType } from "./api/remixed"; 3 | import { threadFloorsObserver } from "./observers"; 4 | import { PerfType, perfProfile } from "./user-values"; 5 | import { waitUntil } from "./utils"; 6 | 7 | export function loadPerf() { 8 | setPerfAttr(); 9 | setThreadLazyload(); 10 | } 11 | 12 | /** 13 | * 根据性能配置对 `` 标签添加对应的属性开关,供 CSS 等进行使用 14 | */ 15 | export function setPerfAttr() { 16 | const perfAttr: Record = { 17 | default: "perf-default", 18 | saver: "perf-saver", 19 | performance: "perf-performance", 20 | }; 21 | 22 | _.forEach(document.documentElement.attributes, attr => { 23 | if (_.startsWith(attr.name, "perf-")) { 24 | document.documentElement.removeAttribute(attr.name); 25 | } 26 | }); 27 | document.documentElement.toggleAttribute(perfAttr[perfProfile.get()]); 28 | } 29 | 30 | /** 31 | * 帖子页面懒加载性能配置 32 | * 33 | * 针对不同性能配置,对楼中楼懒加载范围进行调整。高性能模式下会直接加载整页的评论,以减少视觉抖动;而节能配置被设定为贴吧默认值 (500). 34 | */ 35 | export async function setThreadLazyload() { 36 | if (currentPageType() !== "thread") return; 37 | const lazyloadDiff: Record = { 38 | default: 1000, 39 | saver: 500, 40 | performance: 9999, 41 | }; 42 | await waitUntil(() => typeof datalazyload !== "undefined"); 43 | 44 | threadFloorsObserver.addEvent(setDiff); 45 | 46 | function setDiff() { 47 | // 立即生效可能会被贴吧再次覆盖为原始值,所以延迟一段时间 48 | setTimeout(() => { 49 | datalazyload.userConfig.diff = lazyloadDiff[perfProfile.get()]; 50 | }, 500); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/render/layout/float.ts: -------------------------------------------------------------------------------- 1 | import { scrollbarWidth } from "@/lib/render"; 2 | 3 | export type FloatMode = "baseline" | "middle"; 4 | 5 | export function getFloatCoord( 6 | el: HTMLElement, 7 | coord: Coord, 8 | mode: FloatMode 9 | ): Coord; 10 | export function getFloatCoord( 11 | width: number, 12 | height: number, 13 | coord: Coord, 14 | mode: FloatMode 15 | ): Coord; 16 | 17 | export function getFloatCoord(...args: any[]): Coord { 18 | if (args[0] instanceof HTMLElement) 19 | return getFloatCoord1(args[0], args[1], args[2]); 20 | if (typeof args[0] === "number" && typeof args[1] === "number") 21 | return getFloatCoord2(args[0], args[1], args[2], args[3]); 22 | return { x: 0, y: 0 }; 23 | } 24 | 25 | function getFloatCoord1( 26 | el: HTMLElement, 27 | coord: Coord, 28 | mode: FloatMode 29 | ): Coord { 30 | const clientRect = el.getBoundingClientRect(); 31 | return getFloatCoord2(clientRect.width, clientRect.height, coord, mode); 32 | } 33 | 34 | function getFloatCoord2( 35 | width: number, 36 | height: number, 37 | coord: Coord, 38 | mode: FloatMode 39 | ): Coord { 40 | const offsetX = (() => { 41 | switch (mode) { 42 | case "baseline": return 0; 43 | case "middle": return width / 2; 44 | } 45 | })(); 46 | const x = Math.min( 47 | coord.x - offsetX, 48 | window.innerWidth - scrollbarWidth() 49 | - Math.ceil(width) // 修正误差 50 | ); 51 | const y = 52 | Math.ceil(coord.y + height) > window.innerHeight 53 | ? coord.y - height 54 | : coord.y; 55 | 56 | return { x, y }; 57 | } 58 | -------------------------------------------------------------------------------- /src/stylesheets/main/animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes kf-fade-in { 2 | 0% { 3 | opacity: 0; 4 | } 5 | 6 | 100% { 7 | opacity: 1; 8 | } 9 | } 10 | 11 | @keyframes kf-fade-out { 12 | 0% { 13 | opacity: 1; 14 | } 15 | 16 | 100% { 17 | opacity: 0; 18 | } 19 | } 20 | 21 | @keyframes kf-slide-in { 22 | 0% { 23 | opacity: 0; 24 | transform: translateY(20%); 25 | } 26 | } 27 | 28 | @keyframes kf-slide-out { 29 | 100% { 30 | opacity: 0; 31 | transform: translateY(20%); 32 | } 33 | } 34 | 35 | @keyframes kf-slide-zoom-in { 36 | 0% { 37 | opacity: 0; 38 | transform: translateY(20%) scale(0.85); 39 | } 40 | } 41 | 42 | @keyframes kf-slide-zoom-out { 43 | 100% { 44 | opacity: 0; 45 | transform: translateY(20%) scale(0.85); 46 | } 47 | } 48 | 49 | @keyframes kf-dialog-in { 50 | 0% { 51 | opacity: 0; 52 | transform: scale(1.2); 53 | } 54 | 55 | 100% { 56 | opacity: 1; 57 | transform: scale(1); 58 | } 59 | } 60 | 61 | @keyframes kf-dialog-out { 62 | 0% { 63 | opacity: 1; 64 | transform: scale(1); 65 | } 66 | 67 | 100% { 68 | opacity: 0; 69 | transform: scale(1.2); 70 | } 71 | } 72 | 73 | @keyframes kf-zoom-in { 74 | 0% { 75 | transform: scale(0.72); 76 | } 77 | 78 | 100% { 79 | transform: scale(1); 80 | } 81 | } 82 | 83 | @keyframes kf-fade-zoom-in { 84 | 0% { 85 | opacity: 0; 86 | transform: scale(0.72); 87 | } 88 | 89 | 100% { 90 | opacity: 1; 91 | transform: scale(1); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | # *Tieba Remix* 6 | 7 | 8 |  9 |  10 |  11 |  12 | 13 | ### “贴吧网页端重塑” 14 | 15 | | [📦 安装](#-安装) | [✅ 兼容性](#✅-兼容性) | [⚠ 需要留意](#-需要留意) | 16 | | ---------------- | --------------------- | ------------------------ | 17 | 18 | 19 | 20 | ## 📦 安装 21 | 22 | 如果你从未使用过油猴脚本,请阅读以下内容。 23 | 24 | ### 浏览器插件 25 | 26 | 你的浏览器需要运行驱动油猴脚本的插件,推荐使用 [Tampermonkey](https://www.tampermonkey.net/). 因为我们会优先适配该插件。 27 | 28 | ### 获取脚本 29 | 30 | 如果你的浏览器已经成功启用油猴脚本插件,点击下面的链接可以获取 `Tieba Remix` 的最新版本: 31 | 32 | | [GitHub BETA](https://raw.githubusercontent.com/HacksawBlade/Tieba-Remix/beta/build/tieba-remix.user.js) | [Gitee BETA](https://gitee.com/HacksawBlade/Tieba-Remix/raw/beta/build/tieba-remix.user.js) | [Greasy Fork (不推荐)](https://greasyfork.org/zh-CN/scripts/486367) | 33 | | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | 34 | 35 | ## ✅ 兼容性 36 | 37 | - 脚本优先适配 Edge + Tampermonkey 38 | - 本项目会更激进地使用较新的 Web API 作为试验,请确保你使用的浏览器版本不要过低 39 | 40 | ## ⚠ 需要留意 41 | 42 | ### 关于 GreasyFork 43 | 44 | 由于油叉平台的一些特殊规范,提供给 GreasyFork 的脚本为特供版,可能会出现运行效率不高、白屏时间过长、第一次启动速度慢等问题,推荐从 GitHub/Gitee 获取。 45 | 46 | ### 完整体验 47 | 48 | - 本项目将不会对贴吧原版的广告做任何处理,如有需要请使用广告屏蔽插件 49 | - 脚本的部分功能可能会对性能有较大影响,可自行开关,介意者勿用 50 | - 脚本包含内置资源,会造成额外流量消耗 51 | -------------------------------------------------------------------------------- /src/components/toggle-panel.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ toggle.icon }} 7 | 8 | {{ toggle.name }} 9 | 10 | 11 | 12 | 13 | 14 | 37 | 38 | 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tieba-remix", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc --noEmit && vite build", 8 | "build-dev": "vite build --mode development", 9 | "build-fork": "vite build --mode fork", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "libelemental": "^1.0.7", 14 | "lodash": "^4.17.21", 15 | "marked": "^9.1.6", 16 | "user-view": "^0.0.11", 17 | "vue": "^3.5.13" 18 | }, 19 | "devDependencies": { 20 | "@types/lodash": "^4.17.14", 21 | "@types/marked": "^5.0.2", 22 | "@types/node": "^20.11.6", 23 | "@typescript-eslint/eslint-plugin": "^5.62.0", 24 | "@typescript-eslint/parser": "^5.62.0", 25 | "@vitejs/plugin-vue": "^5.2.1", 26 | "@vitejs/plugin-vue-jsx": "^3.1.0", 27 | "caniuse-lite": "^1.0.30001690", 28 | "deepmerge": "^4.3.1", 29 | "eslint": "^8.56.0", 30 | "eslint-config-standard-with-typescript": "^34.0.1", 31 | "eslint-plugin-import": "^2.29.1", 32 | "eslint-plugin-n": "^15.7.0", 33 | "eslint-plugin-promise": "^6.1.1", 34 | "eslint-plugin-vue": "^9.20.1", 35 | "pnpm": "^8.14.3", 36 | "postcss": "^8.4.49", 37 | "postcss-html": "^1.6.0", 38 | "postcss-preset-env": "^8.5.1", 39 | "postcss-scss": "^4.0.9", 40 | "sass": "^1.70.0", 41 | "stylelint": "^15.11.0", 42 | "stylelint-config-idiomatic-order": "^9.0.0", 43 | "stylelint-config-recommended-vue": "^1.5.0", 44 | "stylelint-config-standard": "^31.0.0", 45 | "stylelint-config-standard-scss": "^7.0.1", 46 | "stylelint-order": "^6.0.4", 47 | "stylelint-scss": "^4.7.0", 48 | "terser": "^5.27.0", 49 | "type-fest": "^4.32.0", 50 | "typescript": "^5.7.3", 51 | "unplugin-auto-import": "^0.17.5", 52 | "unplugin-vue-components": "^0.26.0", 53 | "vite": "^6.0.7", 54 | "vite-plugin-monkey": "^5.0.5", 55 | "vue-tsc": "^2.2.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:vue/base", 11 | ], 12 | "overrides": [ 13 | ], 14 | "parserOptions": { 15 | "parser": "@typescript-eslint/parser", 16 | "ecmaVersion": "latest", 17 | "ecmaFeatures": { 18 | "jsx": true, 19 | }, 20 | }, 21 | "plugins": [ 22 | "@typescript-eslint", 23 | "eslint-plugin-vue", 24 | ], 25 | "rules": { 26 | "indent": [2, 4, { "SwitchCase": 1 }], 27 | "semi": [2, "always"], 28 | "@typescript-eslint/triple-slash-reference": 0, /* ref */ 29 | "no-var": 1, 30 | "strict": 2, 31 | "spaced-comment": 0, 32 | "no-undef": 0, 33 | "radix": 0, /* praseInt radix */ 34 | "@typescript-eslint/no-unused-expressions": 0, /* 禁止无效表达式 */ 35 | "no-param-reassign": 0, 36 | "@typescript-eslint/no-non-null-asserted-optional-chain": 0, /* 变量不能为 null */ 37 | "@typescript-eslint/ban-ts-comment": 0, /* ts ignore 等 */ 38 | "comma-dangle": [2, { 39 | "arrays": "always-multiline", 40 | "objects": "always-multiline", 41 | "imports": "always-multiline", 42 | "exports": "always-multiline", 43 | "functions": "only-multiline", 44 | }], /* 最后一个逗号 */ 45 | "@typescript-eslint/no-unused-vars": 0, /* 从未使用过的变量,不交给 ESLint 处理 */ 46 | "eqeqeq": 2, 47 | "@typescript-eslint/no-var-requires": 0, 48 | "@typescript-eslint/no-explicit-any": 0, 49 | "quotes": ["warn", "double", { 50 | avoidEscape: true, 51 | allowTemplateLiterals: true, 52 | }], 53 | "prefer-template": "warn", 54 | "@typescript-eslint/no-non-null-assertion": 0, /* 禁止使用 ! */ 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/lib/elemental/styles.ts: -------------------------------------------------------------------------------- 1 | import { GM_addStyle } from "$"; 2 | import _ from "lodash"; 3 | import { waitUntil } from "../utils"; 4 | 5 | export type CSSRule = Partial | Record; 6 | export type CSSObject = Record; 7 | 8 | /** 9 | * 将多组 CSS 规则解析为样式字符串 10 | * @param cssObject 描述 CSS 选择器 + 规则 的对象 11 | */ 12 | export function parseMultiCSS(cssObject: CSSObject) { 13 | return _.flatMapDeep(cssObject, (value, key) => { 14 | return [ 15 | `${key} {`, 16 | ..._.flatMapDeep(value, (v, k) => `${_.startsWith(k, "--") ? k : _.kebabCase(k)}: ${v};`), 17 | "}", 18 | "", 19 | ]; 20 | }).join("\n"); 21 | } 22 | 23 | export function parseCSSRule(cssRule: CSSRule): string { 24 | let css = ""; 25 | _.forOwn(cssRule, (value, key) => { 26 | css += `${_.kebabCase(key)}:${value};`; 27 | }); 28 | return css; 29 | } 30 | 31 | /** 32 | * 注入 CSS 规则 33 | * @param selector 选择器 34 | * @param cssRule 包含 CSS 规则的对象 35 | * @returns 对应的 `style` 元素 36 | */ 37 | export function injectCSSRule(selector: string, cssRule: CSSRule) { 38 | return GM_addStyle(`${selector}{${parseCSSRule(cssRule)}}`); 39 | } 40 | 41 | /** 42 | * 对元素快速设置 CSS 规则 43 | * @param el 待操作 DOM 44 | * @param cssRule CSS 规则 45 | */ 46 | export function assignCSSRule(el: Element, cssRule: CSSRule) { 47 | _.assign((el as HTMLElement).style, cssRule); 48 | } 49 | 50 | /** 51 | * 将样式字符串插入到页面中 52 | * @param style 样式字符串 53 | * @returns 对应的 `style` 元素 54 | */ 55 | export function insertCSS(style: string) { 56 | return GM_addStyle(style); 57 | } 58 | 59 | /** 60 | * 将多个样式字符串插入页面,并能够以较高优先级应用样式,用于覆写原有样式 61 | * @param style 样式字符串 62 | * @returns 对应的 `style` 元素 63 | */ 64 | export function overwriteCSS(...style: string[]) { 65 | const styles: HTMLStyleElement[] = []; 66 | _.forEach(style, styleElement => { 67 | styles.push(insertCSS(styleElement)); 68 | }); 69 | waitUntil(() => !_.isNil(document.body)).then(() => { 70 | _.forEach(styles, styleElement => { 71 | document.head.appendChild(styleElement); 72 | }); 73 | }); 74 | return styles; 75 | } 76 | -------------------------------------------------------------------------------- /src/components/await-dialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 54 | 55 | 88 | -------------------------------------------------------------------------------- /src/modules/remixed-theme/tieba-components/nav-bar.scss: -------------------------------------------------------------------------------- 1 | #com_userbar { 2 | display: none; 3 | } 4 | 5 | // @import "@/stylesheets/main/remixed-main"; 6 | 7 | // $nav-height: 48px; 8 | 9 | // #com_userbar { 10 | // position: fixed; 11 | // top: 0; 12 | // left: 0; 13 | // display: flex; 14 | // width: 100%; 15 | // height: $nav-height; 16 | // justify-content: space-around; 17 | // backdrop-filter: blur(24px); 18 | // background-color: var(--trans-default-background); 19 | // gap: 8px; 20 | 21 | // a { 22 | // @extend %anchor; 23 | 24 | // display: block; 25 | // color: var(--default-fore); 26 | // text-decoration: none; 27 | // } 28 | 29 | // .u_split, 30 | // .u_bdhome, 31 | // .u_official, 32 | // .i-arrow-down { 33 | // display: none; 34 | // } 35 | 36 | // ul li { 37 | // height: $nav-height; 38 | 39 | // .u_menu_item { 40 | // height: $nav-height; 41 | // padding: 0; 42 | 43 | // a { 44 | // height: $nav-height; 45 | // padding: 0 20px; 46 | // font-size: 15px; 47 | // line-height: $nav-height; 48 | // } 49 | 50 | // .i-member { 51 | // display: none; 52 | // } 53 | // } 54 | // } 55 | // } 56 | 57 | // .u_ddl { 58 | // top: calc($nav-height - 4px); 59 | // left: 0 !important; 60 | // overflow: hidden; 61 | // min-width: 100px; 62 | // padding: 4px; 63 | // border: 1px solid var(--border-color); 64 | // border-radius: 6px; 65 | // background-color: var(--default-background); 66 | // box-shadow: 0 0 10px rgba($color: #000, $alpha: 10%); 67 | // font-size: 13px !important; 68 | 69 | // .u_ddl_arrow { 70 | // display: none; 71 | // } 72 | 73 | // .u_ddl_con { 74 | // border: none; 75 | // background: none; 76 | // box-shadow: none; 77 | 78 | // .u_ddl_con_top { 79 | // background: none; 80 | 81 | // li { 82 | // height: auto !important; 83 | 84 | // a { 85 | // padding: 0 10px; 86 | // border-radius: 4px; 87 | // } 88 | // } 89 | // } 90 | // } 91 | // } 92 | -------------------------------------------------------------------------------- /src/lib/observers.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { dom } from "./elemental"; 3 | 4 | export class TbObserver { 5 | constructor(selector: string, options?: MutationObserverInit, initEvent?: keyof WindowEventMap) { 6 | this.selector = selector; 7 | this.options = options; 8 | this.initEvent = initEvent; 9 | } 10 | 11 | private readonly selector: string; 12 | private readonly options: MutationObserverInit | undefined; 13 | private readonly initEvent: keyof WindowEventMap | undefined; 14 | 15 | readonly events: (() => void)[] = []; 16 | 17 | public observe() { 18 | const eventFuncs = () => { 19 | this.events.forEach(func => { 20 | func(); 21 | }); 22 | }; 23 | 24 | if (typeof this.initEvent === "undefined") { 25 | eventFuncs(); 26 | } else { 27 | window.addEventListener(this.initEvent, eventFuncs); 28 | } 29 | 30 | const observer = new MutationObserver(eventFuncs); 31 | const obsElem = dom(this.selector); 32 | if (obsElem) observer.observe(obsElem, this.options); 33 | } 34 | 35 | public addEvent(...events: (() => void)[]) { 36 | _.forEach(events, event => { 37 | if (this.events.includes(event)) return; 38 | if (typeof this.initEvent === "undefined") { 39 | event(); 40 | } else { 41 | window.addEventListener(this.initEvent, event); 42 | } 43 | this.events.push(event); 44 | }); 45 | } 46 | } 47 | 48 | /** 贴吧监控 */ 49 | // export const remixedObservers = Object.freeze({ 50 | // /** 楼层监控 */ 51 | // postsObserver: new TbObserver("#j_p_postlist", { childList: true }), 52 | // /** 楼中楼监控 */ 53 | // commentsObserver: new TbObserver("#j_p_postlist", { childList: true, subtree: true }), 54 | // /** 首页动态监控 */ 55 | // newListObserver: new TbObserver("#new_list", { childList: true }), 56 | // /** 进吧页面贴子监控 */ 57 | // threadListObserver: new TbObserver("#pagelet_frs-list\\/pagelet\\/thread", { attributes: true }, "load"), 58 | // }); 59 | 60 | /** 帖子页面 楼层监控 */ 61 | export const threadFloorsObserver = new TbObserver("#j_p_postlist", { childList: true }); 62 | /** 帖子页面 楼中楼监控 */ 63 | export const threadCommentsObserver = new TbObserver("#j_p_postlist", { childList: true, subtree: true }); 64 | /** 旧版主页 推送监控 */ 65 | export const legacyIndexFeedsObserver = new TbObserver("#new_list", { childList: true }); 66 | /** 进吧页面 贴子监控 */ 67 | export const forumThreadsObserver = new TbObserver("#pagelet_frs-list\\/pagelet\\/thread", { attributes: true }, "load"); 68 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { GM_registerMenuCommand } from "$"; 2 | import _ from "lodash"; 3 | import "user-view/build/index.css"; 4 | import Settings from "./components/settings.vue"; 5 | import { checkUpdateAndNotify, currentPageType, setTheme } from "./lib/api/remixed"; 6 | import { parseUserModules } from "./lib/common/packer"; 7 | import { forumThreadsObserver, legacyIndexFeedsObserver, threadCommentsObserver, threadFloorsObserver } from "./lib/observers"; 8 | import { loadPerf } from "./lib/perf"; 9 | import { renderDialog } from "./lib/render"; 10 | import { darkPrefers, loadDynamicCSS, loadMainCSS } from "./lib/theme"; 11 | import index from "./lib/theme/page-extension/index"; 12 | import thread from "./lib/theme/page-extension/thread"; 13 | import { REMIXED, pageExtension, themeType, wideScreen } from "./lib/user-values"; 14 | import { AllModules, waitUntil } from "./lib/utils"; 15 | 16 | // 尽早完成主题设置,降低闪屏概率 17 | setTheme(themeType.get()); 18 | darkPrefers.addEventListener("change", () => setTheme(themeType.get())); 19 | 20 | Promise.all([ 21 | loadDynamicCSS(), 22 | loadMainCSS(), 23 | index(), 24 | thread(), 25 | parseUserModules( 26 | import.meta.glob("./modules/**/index.ts"), 27 | module => { 28 | AllModules().push(module); 29 | } 30 | ), 31 | document.addEventListener("DOMContentLoaded", function () { 32 | if (currentPageType() === "thread") { 33 | threadFloorsObserver.observe(); 34 | threadCommentsObserver.observe(); 35 | } 36 | 37 | if (currentPageType() === "index") { 38 | if (!pageExtension.get().index) 39 | legacyIndexFeedsObserver.observe(); 40 | } 41 | 42 | if (currentPageType() === "forum") { 43 | forumThreadsObserver.observe(); 44 | } 45 | }), 46 | ]); 47 | 48 | window.addEventListener("load", function () { 49 | checkUpdateAndNotify(); 50 | }); 51 | 52 | // 收缩视图检测 53 | waitUntil(() => !_.isNil(document.body)).then(function () { 54 | if (wideScreen.get().noLimit) { 55 | document.body.classList.add("shrink-view"); 56 | } else { 57 | const shrinkListener = _.throttle(function () { 58 | if (window.innerWidth <= wideScreen.get().maxWidth) { 59 | document.body.classList.add("shrink-view"); 60 | } else { 61 | document.body.classList.remove("shrink-view"); 62 | } 63 | }, 200); 64 | 65 | shrinkListener(); 66 | window.addEventListener("resize", shrinkListener); 67 | } 68 | }); 69 | 70 | // 性能配置 71 | loadPerf(); 72 | 73 | GM_registerMenuCommand("设置", () => renderDialog(Settings)); 74 | 75 | console.info(REMIXED); 76 | -------------------------------------------------------------------------------- /src/stylesheets/modules/common.scss: -------------------------------------------------------------------------------- 1 | // 图标 2 | %icon, 3 | %outline-icon { 4 | font-family: "Material Symbols", monospace !important; 5 | font-variation-settings: 6 | "FILL" 0, 7 | "wght" 400, 8 | "GRAD" 0, 9 | "opsz" 40; 10 | font-weight: normal; 11 | user-select: none; 12 | } 13 | 14 | %filled-icon { 15 | font-family: "Material Symbols", monospace !important; 16 | font-variation-settings: 17 | "FILL" 1, 18 | "wght" 400, 19 | "GRAD" 0, 20 | "opsz" 40; 21 | font-weight: normal; 22 | user-select: none; 23 | } 24 | 25 | // 超链接 26 | %anchor { 27 | color: var(--tieba-theme-fore); 28 | cursor: pointer; 29 | text-decoration: none; 30 | transition: var(--default-duration); 31 | } 32 | 33 | %anchor-underline { 34 | @extend %anchor; 35 | text-decoration: underline 1.2px; 36 | } 37 | 38 | %anchor-noback { 39 | cursor: pointer; 40 | text-decoration: none; 41 | transition: var(--default-duration); 42 | } 43 | 44 | %anchor:hover, 45 | %anchor-underline:hover { 46 | background-color: var(--default-hover); 47 | } 48 | 49 | %anchor-underline:hover { 50 | text-decoration: underline 1.2px rgba($color: #000, $alpha: 0%); 51 | } 52 | 53 | %anchor-noback:hover { 54 | color: var(--tieba-theme-fore); 55 | } 56 | 57 | %anchor:active, 58 | %anchor-underline:active { 59 | background-color: var(--default-active); 60 | } 61 | 62 | %anchor-noback:active { 63 | color: var(--tieba-theme-active); 64 | } 65 | 66 | // 头像 67 | %avatar-fit { 68 | object-fit: contain; 69 | } 70 | 71 | @mixin blur-effect($radius: 24px) { 72 | html:not([perf-saver]) & { 73 | backdrop-filter: blur($radius); 74 | } 75 | 76 | html.dark-theme & { 77 | backdrop-filter: blur($radius) brightness(0.8); 78 | } 79 | } 80 | 81 | @mixin raster-effect($back-color: var(--trans-page-background), 82 | $base-color: var(--page-background), $size: 4px, $radius: 4px) { 83 | html:not([perf-saver]) & { 84 | backdrop-filter: saturate(0.8) blur($radius); 85 | background-color: $back-color; 86 | background-image: radial-gradient(transparent 1px, $base-color 1px); 87 | background-size: $size $size; 88 | } 89 | } 90 | 91 | @mixin main-box-shadow($offset-x: 0, $offset-y: 0) { 92 | box-shadow: $offset-x $offset-y 10px rgba($color: #000, $alpha: 20%); 93 | 94 | html.dark-theme & { 95 | box-shadow: $offset-x $offset-y 16px rgba($color: #000, $alpha: 40%); 96 | } 97 | } 98 | 99 | @mixin blur-if-custom-background { 100 | body.custom-background & { 101 | @include blur-effect; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json"; 2 | 3 | type Maybe = T | undefined; 4 | 5 | interface LiteralObject { 6 | [prop: string]: T 7 | } 8 | 9 | type ValueOf = T[keyof T] 10 | 11 | type Mapped = { 12 | [prop in keyof T]: T[prop] 13 | } 14 | 15 | type KeyMapped = { 16 | [prop in keyof T]: U 17 | } 18 | 19 | type OptionalMapped = { 20 | [prop in keyof T]?: T[prop]; 21 | } 22 | 23 | type PageType = "index" | "thread" | "forum" | "user" | "unhandled" 24 | 25 | /** 用户模块 */ 26 | interface UserModule { 27 | [prop: string]: any 28 | 29 | id: string 30 | /** 需要显示给用户的模块名称 */ 31 | name: string 32 | author: string 33 | version: string 34 | brief: string 35 | description: string 36 | 37 | switch?: boolean 38 | scope: true | PageType[] | RegExp 39 | runAt: "immediately" | "afterHead" | "DOMLoaded" | "loaded" 40 | 41 | entry: (() => void) 42 | } 43 | 44 | /** 贴子 */ 45 | interface TiebaPost { 46 | id: string 47 | forum: { 48 | id: string 49 | name: string 50 | href: string 51 | } 52 | 53 | author: { 54 | portrait: string 55 | name: string 56 | href: string 57 | } 58 | time: string 59 | 60 | title: string 61 | content: string 62 | replies: number | string 63 | images: { 64 | thumb: string 65 | original: string 66 | }[] 67 | } 68 | 69 | type DropdownMenu = { 70 | title: string 71 | href?: string 72 | click?: (() => void) 73 | icon?: string 74 | innerText?: string 75 | } | "separator" 76 | 77 | interface UserValueTS { 78 | value: T 79 | invalidTime: number 80 | } 81 | 82 | interface SimpleButton { 83 | title: string 84 | event: (() => void) 85 | } 86 | 87 | interface Meta { 88 | author: string; 89 | description: string; 90 | downloadURL: string; 91 | grant: string[]; 92 | icon: string; 93 | icon64: string; 94 | license: string; 95 | match: string[]; 96 | name: string; 97 | namespace: string; 98 | "run-at": string; 99 | updateURL: string; 100 | version: string; 101 | } 102 | 103 | interface Coord { 104 | x: number; 105 | y: number; 106 | } 107 | 108 | declare global { 109 | interface Global { 110 | none: undefined; 111 | } 112 | 113 | const globalThis: Global; 114 | } 115 | 116 | interface EventRecord { 117 | target: EventTarget; 118 | type: string; 119 | callback: ((e: any) => void) | EventListenerObject; 120 | options?: EventListenerOptions | boolean; 121 | } 122 | 123 | interface ThreadPicture { 124 | original: string; 125 | thumbnail: string; 126 | pictureId?: string; 127 | postId?: number; 128 | } 129 | -------------------------------------------------------------------------------- /src/modules/shield/index.ts: -------------------------------------------------------------------------------- 1 | import { UserModuleEx } from "@/ex"; 2 | import { dom } from "@/lib/elemental"; 3 | import { TbObserver, forumThreadsObserver, legacyIndexFeedsObserver, threadCommentsObserver, threadFloorsObserver } from "@/lib/observers"; 4 | import _ from "lodash"; 5 | import { markRaw } from "vue"; 6 | import moduleShieldVue from "./module.shield.vue"; 7 | import { ShieldRule, matchShield, shieldList } from "./shield"; 8 | 9 | export default { 10 | id: "shield", 11 | name: "贴吧屏蔽", 12 | author: "锯条", 13 | version: "1.2", 14 | brief: "眼不见为净", 15 | description: `用户自定义屏蔽规则,符合规则的贴子和楼层将不会显示在首页、看贴页面和进吧页面。支持正则匹配`, 16 | scope: true, 17 | runAt: "immediately", 18 | settings: { 19 | "shield-controls": { 20 | title: "管理屏蔽规则", 21 | description: 22 | `这些屏蔽规则将会在首页、看贴页面生效,会自动隐藏所有符合匹配规则的贴子和楼层。`, 23 | widgets: [{ 24 | type: "component", 25 | component: markRaw(moduleShieldVue), 26 | }], 27 | }, 28 | }, 29 | entry: main, 30 | } as UserModuleEx; 31 | 32 | export * from "./shield"; 33 | 34 | /** 35 | * 通过选择器屏蔽元素 36 | * @param observer 监控 37 | * @param parentSelector 父元素选择器 38 | * @param subSelector 子元素选择器 39 | */ 40 | function shieldBySelector( 41 | observer: TbObserver, 42 | scope: ShieldRule["scope"], 43 | parentSelector: string, 44 | subSelector: string 45 | ) { 46 | observer.addEvent(() => { 47 | dom(parentSelector, []).forEach(elem => { 48 | let isMatch = false; 49 | const content = _.join(_.map(dom(subSelector, elem, []), el => el.textContent ?? ""), "\n"); 50 | 51 | for (const rule of shieldList.get()) { 52 | if (matchShield(rule, content, scope)) { 53 | isMatch = true; 54 | break; 55 | } 56 | } 57 | 58 | if (isMatch) { 59 | (elem as HTMLElement).style.display = "none"; 60 | } 61 | }); 62 | }); 63 | } 64 | 65 | function main() { 66 | // 看贴页面 67 | shieldBySelector(threadFloorsObserver, "content", ".l_post_bright", ".d_post_content"); 68 | shieldBySelector(threadFloorsObserver, "username", ".l_post_bright", ".p_author_name"); 69 | shieldBySelector(threadCommentsObserver, "content", ".lzl_single_post", ".lzl_content_main"); 70 | shieldBySelector(threadCommentsObserver, "username", ".lzl_single_post", ".lzl_cnt .j_user_card"); 71 | // 首页动态 72 | shieldBySelector(legacyIndexFeedsObserver, "content", ".j_feed_li", ".title, .n_txt"); 73 | shieldBySelector(legacyIndexFeedsObserver, "username", ".j_feed_li", ".post_author"); 74 | // 进吧页面 75 | shieldBySelector(forumThreadsObserver, "content", ".j_thread_list", ".threadlist_title a"); 76 | shieldBySelector(forumThreadsObserver, "username", ".j_thread_list", ".frs-author-name-wrap"); 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/portal/index.ts: -------------------------------------------------------------------------------- 1 | import { dom } from "@/lib/elemental"; 2 | import { threadCommentsObserver } from "@/lib/observers"; 3 | import _ from "lodash"; 4 | 5 | export default { 6 | id: "portal", 7 | name: "传送门", 8 | author: "锯条", 9 | version: "1.1.1", 10 | brief: "为贴子中的b站番号添加跳转链接", 11 | description: `该模块可以识别贴子中的 av/BV 号并将其转换为超链接`, 12 | scope: ["thread"], 13 | runAt: "immediately", 14 | entry: main, 15 | } as UserModule; 16 | 17 | function main(): void { 18 | const LINKED_CLASS = "linked"; 19 | const avRegExp = /(? { 23 | threadCommentsObserver.addEvent(biliPortal); 24 | }); 25 | 26 | /* av/BV 快速跳转 */ 27 | function biliPortal() { 28 | addBiliLinks(".d_post_content"); 29 | addBiliLinks(".lzl_cnt .lzl_content_main"); 30 | 31 | function addBiliLinks(selector: string): void { 32 | _.forEach(dom(selector, []), (elem) => { 33 | if (elem.classList.contains(LINKED_CLASS)) return; 34 | elem.classList.add(LINKED_CLASS); 35 | 36 | // av号 37 | if (elem.textContent?.toLowerCase().indexOf("av") !== -1) { 38 | const avs = elem.textContent?.match(avRegExp); 39 | bindingLinks(avs ?? undefined, true); 40 | } 41 | 42 | // BV号 43 | if (elem.textContent?.indexOf("BV") !== -1) { 44 | const BVs = elem.textContent?.match(BVRegExp); 45 | bindingLinks(BVs ?? undefined); 46 | } 47 | 48 | function bindingLinks( 49 | array: Maybe, 50 | lowerCase = false 51 | ) { 52 | if (!array) return; 53 | 54 | const hadHyperLink: string[] = []; 55 | _.forEach(array, (videoID) => { 56 | if (hadHyperLink.indexOf(videoID) === -1) { 57 | hadHyperLink.push(videoID); 58 | const htmlArray = elem.innerHTML.split( 59 | RegExp(`(?${videoID 65 | }`; 66 | elem.innerHTML = htmlArray.join(linkedID); 67 | } 68 | }); 69 | } 70 | }); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/utils/color.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | export interface RGBA { 4 | r: number; 5 | g: number; 6 | b: number; 7 | a: number; 8 | } 9 | 10 | export interface HSLA { 11 | h: number; 12 | s: string; 13 | l: string; 14 | a: number; 15 | } 16 | 17 | export function colorToRGBA(color: string): Maybe { 18 | const elem = document.createElement("div"); 19 | elem.style.color = color; 20 | document.body.appendChild(elem); 21 | const computedColor = window.getComputedStyle(elem).color; 22 | document.body.removeChild(elem); 23 | const rgbaMatch = computedColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/); 24 | if (rgbaMatch) { 25 | return { 26 | r: parseInt(rgbaMatch[1], 10), 27 | g: parseInt(rgbaMatch[2], 10), 28 | b: parseInt(rgbaMatch[3], 10), 29 | a: parseFloat(rgbaMatch[4] ?? "1"), 30 | }; 31 | } 32 | } 33 | 34 | export function hexToRGBA(hex: string): RGBA { 35 | const hexValue = _.startsWith(hex, "#") ? _.trimStart(hex, "#") : hex; 36 | const tokenConverter = hexValue.length <= 4 37 | ? (chunk: string[]) => parseInt(_.repeat(chunk[0], 2), 16) 38 | : (chunk: string[]) => parseInt(_.join(chunk, ""), 16); 39 | const chunkSize = hexValue.length <= 4 ? 1 : 2; 40 | const chunks = _.chunk(hexValue, chunkSize); 41 | return { 42 | r: tokenConverter(chunks[0]), 43 | g: tokenConverter(chunks[1]), 44 | b: tokenConverter(chunks[2]), 45 | a: chunks.length === 4 ? tokenConverter(chunks[3]) : 1, 46 | }; 47 | } 48 | 49 | export function rgbaToHSLA(rgba: RGBA): HSLA { 50 | // 将 RGB 值归一化到范围 [0, 1] 51 | const normalizedR = rgba.r / 255; 52 | const normalizedG = rgba.g / 255; 53 | const normalizedB = rgba.b / 255; 54 | 55 | const minValue = Math.min(normalizedR, normalizedG, normalizedB); 56 | const maxValue = Math.max(normalizedR, normalizedG, normalizedB); 57 | 58 | const lightness = (maxValue + minValue) / 2; 59 | 60 | let saturation; 61 | if (lightness <= 0.5) { 62 | saturation = (maxValue - minValue) / (maxValue + minValue); 63 | } else { 64 | saturation = (maxValue - minValue) / (2 - maxValue - minValue); 65 | } 66 | 67 | let hue; 68 | if (maxValue === minValue) { 69 | hue = 0; 70 | } else if (maxValue === normalizedR) { 71 | hue = (normalizedG - normalizedB) / (maxValue - minValue); 72 | } else if (maxValue === normalizedG) { 73 | hue = 2 + (normalizedB - normalizedR) / (maxValue - minValue); 74 | } else { 75 | hue = 4 + (normalizedR - normalizedG) / (maxValue - minValue); 76 | } 77 | 78 | hue *= 60; 79 | if (hue < 0) { 80 | hue += 360; 81 | } 82 | 83 | return { 84 | h: _.round(hue, 2), 85 | s: `${_.round(saturation * 100)}%`, 86 | l: `${_.round(lightness * 100)}%`, 87 | a: rgba.a, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/lib/common/settings/setting-widgets/layout.custom-back.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 清除 7 | 上传图片 8 | 9 | 17 | 18 | 19 | 20 | 52 | 53 | 92 | -------------------------------------------------------------------------------- /src/modules/shield/shield-editor.vue: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 规则 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 删除规则 17 | 18 | 19 | 20 | 21 | 62 | 63 | 97 | -------------------------------------------------------------------------------- /src/lib/common/packer.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { currentPageType } from "../api/remixed"; 3 | import { afterHead } from "../elemental"; 4 | import { disabledModules } from "../user-values"; 5 | 6 | /** 7 | * 解析用户模块,并根据默认情况按需执行模块 8 | * @param glob 9 | * @param callbackfn 10 | * @returns 所有解析后的模块 11 | */ 12 | export function parseUserModules( 13 | glob: Record Promise>, 14 | callbackfn?: ((module: UserModule) => void) 15 | ): UserModule[] { 16 | const modules: UserModule[] = []; 17 | 18 | _.forEach(glob, async moduleExport => { 19 | const currentModule = (await moduleExport()).default as UserModule; 20 | const disabledSet = new Set(disabledModules.get()); 21 | 22 | // 先判断模块是否开启 23 | const runnable = (() => { 24 | if (currentModule.switch || currentModule.switch === undefined) { 25 | // 用户配置优先级最高,可以直接否决 26 | if (disabledSet.has(currentModule.id)) { 27 | return false; 28 | } 29 | 30 | // 判断当前模块是否在作用域内 31 | // 始终运行 32 | if (currentModule.scope === true) { 33 | return true; 34 | } 35 | 36 | // 数组 37 | if (Array.isArray(currentModule.scope)) { 38 | for (let i = 0; i < currentModule.scope.length; i++) { 39 | const scope = currentModule.scope[i]; 40 | if (currentPageType() === scope) { 41 | return true; 42 | } 43 | } 44 | } 45 | 46 | // 正则表达式 47 | if (currentModule.scope instanceof RegExp) { 48 | if (currentModule.scope.test(location.href)) { 49 | return true; 50 | } 51 | } 52 | } 53 | 54 | return false; 55 | })(); 56 | 57 | // 根据模块 runAt 选择运行模式 58 | const runModule = { 59 | "immediately": () => { currentModule.entry(); }, 60 | 61 | "afterHead": () => { 62 | afterHead(() => { 63 | currentModule.entry(); 64 | }); 65 | }, 66 | 67 | "DOMLoaded": () => { 68 | document.addEventListener("DOMContentLoaded", () => { 69 | currentModule.entry(); 70 | }); 71 | }, 72 | 73 | "loaded": () => { 74 | window.addEventListener("load", () => { 75 | currentModule.entry(); 76 | }); 77 | }, 78 | }; 79 | 80 | currentModule.runnable = runnable; 81 | if (runnable) { 82 | runModule[currentModule.runAt](); 83 | } 84 | 85 | modules.push(currentModule); 86 | 87 | // 处理回调函数 88 | if (callbackfn) callbackfn(currentModule); 89 | }); 90 | 91 | return modules; 92 | } 93 | -------------------------------------------------------------------------------- /src/modules/shield/shield.ts: -------------------------------------------------------------------------------- 1 | import { UserKey } from "@/lib/user-values"; 2 | import _ from "lodash"; 3 | 4 | /** 5 | * 屏蔽规则对象 6 | */ 7 | export interface ShieldRule { 8 | /** 匹配规则,它可能是直接的屏蔽词,也可能是正则表达式 */ 9 | content: string; 10 | /** 描述当前规则的类型 */ 11 | type: "text" | "regex"; 12 | /** 作用域,屏蔽规则作用于贴子或用户 */ 13 | scope: "content" | "username"; 14 | /** 是否启用该规则 */ 15 | toggle: boolean; 16 | /** 是否忽略大小写,默认忽略 */ 17 | ignoreCase?: boolean; 18 | /** 是否匹配 innerHTML?默认匹配 textContent */ 19 | matchHTML?: boolean; 20 | } 21 | 22 | export interface ShieldRuleLegacy { 23 | rule: string; 24 | type: "string" | "regex"; 25 | scope: "posts" | "users"; 26 | switch: boolean; 27 | ignoreCase?: boolean; 28 | matchHTML?: boolean; 29 | } 30 | 31 | export const shieldList = new UserKey( 32 | "shieldList", [], undefined, (maybeLegacy) => _.map(maybeLegacy, shieldRuleMigration) 33 | ); 34 | 35 | /** 36 | * 匹配字符串是否和屏蔽对象规则符合 37 | * @param rule 屏蔽对象 38 | * @param str 需要匹配的字符串 39 | * @param scope 作用域,屏蔽规则作用于内容或用户 40 | * @returns 是否匹配成功 41 | */ 42 | export function matchShield(rule: ShieldRule, str: string, scope: ShieldRule["scope"]): boolean { 43 | // 规则未启用,直接返回 44 | if (!rule.toggle) return false; 45 | 46 | // 作用域不匹配,直接返回 47 | if (rule.scope !== scope) return false; 48 | 49 | // 可选参数 50 | if (rule.ignoreCase === undefined) rule.ignoreCase = true; 51 | 52 | // 字符串 53 | if (rule.type === "text") { 54 | // 忽略大小写,先转为小写 55 | if (rule.ignoreCase) { 56 | rule.content = rule.content.toLowerCase(); 57 | str = str.toLowerCase(); 58 | } 59 | 60 | if (str.indexOf(rule.content) !== -1) { 61 | return true; 62 | } 63 | } 64 | 65 | // 正则 66 | if (rule.type === "regex") { 67 | let regex: RegExp; 68 | 69 | // 忽略大小写 70 | if (rule.ignoreCase) { 71 | regex = new RegExp(rule.content, "i"); 72 | } else { 73 | regex = new RegExp(rule.content); 74 | } 75 | 76 | if (regex.test(str)) { 77 | return true; 78 | } 79 | } 80 | 81 | return false; 82 | } 83 | 84 | export function shieldRuleMigration(rule: ShieldRule | ShieldRuleLegacy): ShieldRule { 85 | if (!_.has(rule, "rule")) return rule as ShieldRule; 86 | rule = rule as ShieldRuleLegacy; 87 | 88 | const newRule: ShieldRule = { 89 | content: rule.rule, 90 | type: "text", 91 | scope: "content", 92 | toggle: rule.switch, 93 | ignoreCase: rule.ignoreCase, 94 | matchHTML: rule.matchHTML, 95 | }; 96 | 97 | if (rule.type === "string") newRule.type = "text"; 98 | if (rule.scope === "posts") newRule.scope = "content"; 99 | if (rule.scope === "users") newRule.scope = "username"; 100 | 101 | return newRule; 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/tieba-components/pager.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { TiebaComponent } from "../api/abstract"; 3 | import { dom } from "../elemental"; 4 | 5 | export type PagerType = "prev" | "next" | "head" | "tail" | "page"; 6 | 7 | export class Pager extends TiebaComponent<"li"> { 8 | public allPagerButtons(): Array { 9 | return dom<"a" | "span">("a, .tP", this.get(), []); 10 | } 11 | 12 | public getPagerButton(pagerType: PagerType, index = 0) { 13 | const allButtons = this.allPagerButtons(); 14 | 15 | switch (pagerType) { 16 | case "prev": { 17 | return this.findMatchingButton(allButtons, "上一页"); 18 | } 19 | 20 | case "next": { 21 | return this.findMatchingButton(allButtons, "下一页", true); 22 | } 23 | 24 | case "head": { 25 | return this.findMatchingButton(allButtons, "首页"); 26 | } 27 | 28 | case "tail": { 29 | return this.findMatchingButton(allButtons, "尾页", true); 30 | } 31 | 32 | case "page": { 33 | let count = 0; 34 | for (const el of allButtons) { 35 | if (/^\d+$/.test(el.innerText)) { 36 | if (count === index && el instanceof HTMLAnchorElement) { 37 | return el; 38 | } 39 | count++; 40 | } 41 | } 42 | return null; // 没有找到对应索引的按钮 43 | } 44 | 45 | default: 46 | return null; // 未知的 pagerType 47 | } 48 | } 49 | 50 | public getByPage(page: number) { 51 | return this.findMatchingButton(this.allPagerButtons(), page.toString()); 52 | } 53 | 54 | public jumpTo(page: number) { 55 | const permKeys = ["pn", "see_lz"]; 56 | const params = new URLSearchParams(location.search); 57 | const newParams = new URLSearchParams(); 58 | for (const [key, value] of params) { 59 | if (_.includes(permKeys, key)) { 60 | newParams.set(key, value); 61 | } 62 | } 63 | const url = new URL(location.href); 64 | url.search = newParams.toString(); 65 | history.pushState({}, "", url); 66 | 67 | const jumperBox = dom<"input">("#jumpPage4, #jumpPage6"); 68 | const jumperButton = dom<"button">("#pager_go4, #pager_go6"); 69 | if (jumperBox) jumperBox.value = page.toString(); 70 | jumperButton?.click(); 71 | } 72 | 73 | private findMatchingButton(buttons: HTMLElement[], text: string, reverse = false) { 74 | const iterator = reverse ? Array.from(buttons).reverse() : buttons; 75 | for (const el of iterator) { 76 | if (el.innerText === text) { 77 | return el as HTMLAnchorElement; 78 | } 79 | } 80 | return null; 81 | } 82 | } 83 | 84 | export const pager = new Pager(".l_pager"); 85 | -------------------------------------------------------------------------------- /src/lib/common/settings/setting-widgets/about.detail.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ MainTitle }} 7 | 8 | 9 | 10 | 11 | {{ scriptInfo.script.version }} 12 | @{{ scriptInfo.script.author }} 13 | 14 | 15 | 16 | {{ line }} 17 | 18 | 19 | 20 | 21 | 开放源代码 23 | 24 | 25 | 27 | 检查更新 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 46 | 47 | 103 | -------------------------------------------------------------------------------- /src/modules/remixed-theme/index.ts: -------------------------------------------------------------------------------- 1 | import { dom, fadeInElems, fadeInLoad } from "@/lib/elemental"; 2 | import { injectCSSRule, overwriteCSS } from "@/lib/elemental/styles"; 3 | import { threadFloorsObserver } from "@/lib/observers"; 4 | import { setCustomBackground } from "@/lib/theme"; 5 | import "@/stylesheets/components/user-button.scss"; 6 | import _ from "lodash"; 7 | import floatBarStyle from "./tieba-components/float-bar.scss?inline"; 8 | import _navBar from "./tieba-components/nav-bar"; 9 | 10 | export default { 11 | id: "remixed-theme", 12 | name: "Tieba Remix 主题", 13 | author: "锯条", 14 | version: "0.3", 15 | brief: "更现代的主题样式", 16 | description: `包含新的样式、昼夜主题及其自动切换等功能`, 17 | scope: true, 18 | runAt: "immediately", 19 | entry: main, 20 | } as UserModule; 21 | 22 | function main(): void { 23 | _navBar(); 24 | overwriteCSS(floatBarStyle); 25 | 26 | // 耗时加载元素 27 | fadeInElems.push(".tbui_aside_float_bar .svg-container"); 28 | fadeInElems.push(".d_badge_bright .d_badge_lv, .user_level .badge_index"); 29 | 30 | // 让耗时加载元素默认不透明度为0 31 | fadeInElems.forEach(selector => { 32 | injectCSSRule(selector, { 33 | opacity: "0", 34 | }); 35 | }); 36 | 37 | setCustomBackground(); 38 | 39 | document.addEventListener("DOMContentLoaded", () => { 40 | // 修改元素 41 | dom(".post-tail-wrap .icon-jubao", []).forEach(elem => { 42 | elem.removeAttribute("src"); 43 | elem.after("举报"); 44 | }); 45 | 46 | // 远古用户没有等级则隐藏等级标签 47 | threadFloorsObserver.addEvent(() => { 48 | dom<"div">(".d_badge_lv", []).forEach(elem => { 49 | if (elem.textContent === "") { 50 | let parent = elem as HTMLElement; 51 | while (!parent.classList.contains("l_badge")) { 52 | if (parent.parentElement) 53 | parent = parent.parentElement; 54 | } 55 | parent.style.display = "none"; 56 | } 57 | }); 58 | }); 59 | }); 60 | 61 | window.addEventListener("load", () => { 62 | // 功能按钮 svg 延迟 63 | fadeInLoad(".tbui_aside_float_bar .svg-container"); 64 | 65 | // 为吧务和自己的等级染色 66 | threadFloorsObserver.addEvent(() => { 67 | const lvlClassHead = "tieba-lvl-"; 68 | const lvlGreen = `${lvlClassHead}green`; 69 | const lvlBlue = `${lvlClassHead}blue`; 70 | const lvlYellow = `${lvlClassHead}yellow`; 71 | const lvlOrange = `${lvlClassHead}orange`; 72 | 73 | dom( 74 | ".d_badge_bawu1 .d_badge_lv, .d_badge_bawu2 .d_badge_lv, .badge_index", [] 75 | ).forEach(elem => { 76 | if (elem.className.indexOf(lvlClassHead) !== -1) return; 77 | 78 | const lvl = parseInt(_.defaults(elem.textContent, "0")); 79 | if (lvl >= 1 && lvl <= 3) { 80 | elem.classList.add(lvlGreen); 81 | } else if (lvl >= 4 && lvl <= 9) { 82 | elem.classList.add(lvlBlue); 83 | } else if (lvl >= 10 && lvl <= 15) { 84 | elem.classList.add(lvlYellow); 85 | } else if (lvl >= 16) { 86 | elem.classList.add(lvlOrange); 87 | } 88 | }); 89 | 90 | // 等级图标延迟 91 | fadeInLoad(".d_badge_bright .d_badge_lv, .user_level .badge_index"); 92 | }); 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /src/stylesheets/main/palette.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --img-tieba-icon: url("https://gitee.com/HacksawBlade/Tieba-Remix/raw/master/assets/images/main/icon.png"); 3 | } 4 | 5 | .dark-theme { 6 | --default-background: rgb(32 32 32); 7 | --default-hover: rgb(42 42 42); 8 | --default-active: rgb(54 54 54); 9 | --page-background: rgb(26 26 26); 10 | --trans-page-background: rgb(26 26 26 / 60%); 11 | --trans-default-background: rgb(32 32 32 / 60%); 12 | --deep-background: rgb(26 26 26); 13 | --trans-deep-background: rgb(20 20 20 / 60%); 14 | --light-background: rgb(60 60 60); 15 | --trans-light-background: rgb(60 60 60 / 60%); 16 | --very-light-background: rgb(60 60 60); 17 | --elem-color: rgb(26 26 26); 18 | --default-fore: rgb(230 230 230); 19 | --light-fore: rgb(180 180 180); 20 | --minimal-fore: rgb(144 144 144); 21 | --highlight-fore: rgb(255 255 255); 22 | --border-color: rgb(96 96 96 / 60%); 23 | --light-border-color: rgb(96 96 96 / 20%); 24 | 25 | --tieba-theme-color: rgb(113 97 193); 26 | --trans-tieba-theme-color: rgb(113 97 193 / 60%); 27 | --tieba-theme-hover: rgb(149 128 254); 28 | --tieba-theme-active: rgb(172 156 253); 29 | --tieba-theme-background: rgb(113 97 193 / 20%); 30 | --tieba-theme-fore: rgb(150 128 255); 31 | 32 | --level-green-background: rgb(96 153 59 / 10%); 33 | --level-green-fore: rgb(133 206 84); 34 | --level-blue-background: rgb(0 165 227 / 10%); 35 | --level-blue-fore: rgb(0 169 255); 36 | --level-yellow-background: rgb(229 193 90 / 10%); 37 | --level-yellow-fore: rgb(242 205 96); 38 | --level-orange-background: rgb(204 122 0 / 10%); 39 | --level-orange-fore: rgb(255 170 0); 40 | 41 | --check-color: lawngreen; 42 | --error-color: tomato; 43 | --warning-color: orange; 44 | 45 | color-scheme: dark; 46 | } 47 | 48 | .light-theme { 49 | --default-background: rgb(255 255 255); 50 | --default-hover: rgb(240 240 240); 51 | --default-active: rgb(224 224 224); 52 | --page-background: rgb(245 245 245); 53 | --trans-page-background: rgb(245 245 245 / 60%); 54 | --trans-default-background: rgb(255 255 255 / 60%); 55 | --deep-background: rgb(228 228 228); 56 | --trans-deep-background: rgb(200 200 200 / 60%); 57 | --light-background: rgb(235 235 235); 58 | --trans-light-background: rgb(228 228 228 / 60%); 59 | --very-light-background: rgb(245 245 245); 60 | --elem-color: rgb(240 240 240); 61 | --default-fore: rgb(16 16 16); 62 | --light-fore: rgb(86 86 86); 63 | --minimal-fore: rgb(118 118 118); 64 | --highlight-fore: rgb(0 0 0); 65 | --border-color: rgb(185 185 185 / 60%); 66 | --light-border-color: rgb(185 185 185 / 20%); 67 | 68 | --tieba-theme-color: rgb(97 78 194); 69 | --trans-tieba-theme-color: rgb(97 78 194 / 60%); 70 | --tieba-theme-hover: rgb(119 105 194); 71 | --tieba-theme-active: rgb(150 134 232); 72 | --tieba-theme-background: rgb(97 78 194 / 20%); 73 | --tieba-theme-fore: rgb(58 46 116); 74 | 75 | --level-green-background: rgb(84 130 53 / 10%); 76 | --level-green-fore: rgb(51 78 32); 77 | --level-blue-background: rgb(0 153 213 / 10%); 78 | --level-blue-fore: rgb(0 81 111); 79 | --level-yellow-background: rgb(164 139 63 / 10%); 80 | --level-yellow-fore: rgb(124 105 46); 81 | --level-orange-background: rgb(255 153 0 / 10%); 82 | --level-orange-fore: rgb(178 104 0); 83 | 84 | --check-color: green; 85 | --error-color: darkred; 86 | --warning-color: darkorange; 87 | 88 | color-scheme: "light"; 89 | } 90 | -------------------------------------------------------------------------------- /src/modules/toolkit/index.ts: -------------------------------------------------------------------------------- 1 | import { SettingContent } from "@/components/settings.vue"; 2 | import { UserModuleEx } from "@/ex"; 3 | import { tiebaAPI } from "@/lib/api/tieba"; 4 | import { dom, findParent } from "@/lib/elemental"; 5 | import { threadCommentsObserver, threadFloorsObserver } from "@/lib/observers"; 6 | import { UserKey } from "@/lib/user-values"; 7 | import _ from "lodash"; 8 | 9 | export default { 10 | id: "toolkit", 11 | name: "实用工具库", 12 | author: "锯条", 13 | version: "1.1", 14 | brief: "优化原版贴吧体验的一组功能", 15 | description: "这是一个轻量级的工具库,包含了诸如自动展开长图等实用功能。", 16 | scope: true, 17 | runAt: "immediately", 18 | settings: { 19 | autoExpand: { 20 | title: "自动展开长图", 21 | widgets: [{ 22 | type: "toggle", 23 | content: `该功能会自动将帖子中所有的长图片自动展开,无需手动操作`, 24 | init: () => toolkitToggles.get().autoExpand, 25 | event() { 26 | toolkitToggles.merge({ autoExpand: !toolkitToggles.get().autoExpand }); 27 | }, 28 | }], 29 | }, 30 | 31 | reloadAvatars: { 32 | title: "重新加载错误头像", 33 | widgets: [{ 34 | type: "toggle", 35 | content: `原版贴吧的帖子页面时常会出现加载失败的头像,本功能可以将这些无法正常显示的头像资源链接到正常的 URL`, 36 | init: () => toolkitToggles.get().reloadAvatars, 37 | event() { 38 | toolkitToggles.merge({ reloadAvatars: !toolkitToggles.get().reloadAvatars }); 39 | }, 40 | }], 41 | }, 42 | } as Record, 43 | entry: function () { 44 | for (const key in toolkitFeatures) { 45 | const k = key as keyof typeof toolkitFeatures; 46 | if (toolkitToggles.get()[k]) toolkitFeatures[k](); 47 | } 48 | }, 49 | } as UserModuleEx; 50 | 51 | const toolkitFeatures = { 52 | /** 自动展开长图 */ 53 | autoExpand() { 54 | threadFloorsObserver.addEvent(() => { 55 | _.forEach(dom<"div">(".replace_tip", []), (el) => { 56 | el.click(); 57 | }); 58 | }); 59 | }, 60 | 61 | /** 重新加载错误头像 */ 62 | reloadAvatars() { 63 | const observer = new IntersectionObserver(function (entries) { 64 | _.forEach(entries, entry => { 65 | if (entry.isIntersecting) { 66 | const avatar = entry.target as HTMLImageElement; 67 | if (!avatar.complete) return; 68 | if (avatar.naturalWidth > 0) { 69 | avatar.setAttribute("data-loaded", ""); 70 | } else { 71 | const userCard = findParent<"li">(avatar, "j_user_card"); 72 | if (!userCard) return; 73 | const dataField = userCard.getAttribute("data-field"); 74 | if (!dataField) return; 75 | const portarit = JSON.parse(dataField.replaceAll(/'/g, '"')).id; 76 | avatar.src = tiebaAPI.URL_profile(portarit); 77 | avatar.setAttribute("data-loaded", ""); 78 | } 79 | } 80 | }); 81 | }, { threshold: 0 }); 82 | 83 | threadCommentsObserver.addEvent(function () { 84 | const avatars = dom<"img">(".lzl_single_post img:not(.BDE_Smiley, [data-loaded])", []); 85 | avatars.forEach(avatar => observer.observe(avatar)); 86 | }); 87 | }, 88 | }; 89 | 90 | type ToolkitToggles = Record; 91 | 92 | const toolkitToggles = new UserKey("toolkitToggles", { 93 | autoExpand: true, 94 | reloadAvatars: true, 95 | }); 96 | -------------------------------------------------------------------------------- /src/stylesheets/main/universal.scss: -------------------------------------------------------------------------------- 1 | .icon, 2 | .outline-icon { 3 | @extend %icon; 4 | } 5 | 6 | .filled-icon { 7 | @extend %filled-icon; 8 | } 9 | 10 | .anchor { 11 | @extend %anchor; 12 | } 13 | 14 | .anchor-underline { 15 | @extend %anchor-underline; 16 | } 17 | 18 | .anchor-noback { 19 | @extend %anchor-noback; 20 | } 21 | 22 | .markdown { 23 | font-family: var(--code-zh); 24 | font-size: 16px; 25 | 26 | code { 27 | padding: 2px 6px; 28 | border-radius: 8px; 29 | background-color: var(--light-border-color); 30 | font-family: var(--code-monospace); 31 | word-wrap: break-word; 32 | } 33 | 34 | a { 35 | color: var(--tieba-theme-fore); 36 | } 37 | 38 | a:hover { 39 | text-decoration: underline; 40 | } 41 | 42 | h2 { 43 | margin: 20px 0 8px; 44 | font-size: 24px; 45 | } 46 | 47 | h3 { 48 | margin: 16px 0 6px; 49 | font-size: 18px; 50 | } 51 | 52 | ul { 53 | padding: 0; 54 | margin: 6px 0; 55 | } 56 | 57 | li { 58 | margin: 6px 0 6px 22px; 59 | list-style: disc; 60 | } 61 | 62 | li::marker { 63 | color: var(--minimal-fore); 64 | } 65 | 66 | blockquote { 67 | margin: 20px 16px; 68 | color: var(--minimal-fore); 69 | } 70 | 71 | hr { 72 | border: 2px solid var(--border-color); 73 | margin: 10px 0; 74 | } 75 | } 76 | 77 | .settings-toggle-button { 78 | border: none !important; 79 | border-radius: 36px; 80 | background-color: unset !important; 81 | } 82 | 83 | .settings-toggle-button.toggle-off { 84 | color: var(--minimal-fore); 85 | font-variation-settings: "FILL" 0; 86 | } 87 | 88 | .settings-toggle-button.toggle-off::after { 89 | content: "toggle_off"; 90 | } 91 | 92 | .settings-toggle-button.toggle-on::after { 93 | content: "toggle_on"; 94 | } 95 | 96 | .settings-toggle-button.toggle-on { 97 | color: var(--tieba-theme-color); 98 | font-variation-settings: "FILL" 1; 99 | } 100 | 101 | .settings-toggle-button.toggle-off:hover { 102 | color: var(--default-hover); 103 | } 104 | 105 | .settings-toggle-button.toggle-off:active { 106 | color: var(--default-active); 107 | } 108 | 109 | .settings-toggle-button.toggle-on:hover { 110 | color: var(tieba-theme-hover); 111 | } 112 | 113 | .settings-toggle-button.toggle-on:active { 114 | color: var(--tieba-theme-active); 115 | } 116 | 117 | .level-green { 118 | background-color: var(--level-green-background) !important; 119 | color: var(--level-green-fore) !important; 120 | } 121 | 122 | .level-blue { 123 | background-color: var(--level-blue-background) !important; 124 | color: var(--level-blue-fore) !important; 125 | } 126 | 127 | .level-yellow { 128 | background-color: var(--level-yellow-background) !important; 129 | color: var(--level-yellow-fore) !important; 130 | } 131 | 132 | .level-orange { 133 | background-color: var(--level-orange-background) !important; 134 | color: var(--level-orange-fore) !important; 135 | } 136 | 137 | .remove-default { 138 | font-size: 16px; 139 | line-height: normal !important; 140 | 141 | button, 142 | input, 143 | optgroup, 144 | select, 145 | textarea { 146 | font-family: var(--code-zh); 147 | font-size: 16px; 148 | } 149 | 150 | .content { 151 | min-height: unset; 152 | background: unset; 153 | } 154 | 155 | code { 156 | display: unset; 157 | width: unset; 158 | height: unset; 159 | } 160 | 161 | .content, 162 | .foot { 163 | width: unset; 164 | } 165 | 166 | button { 167 | color: unset; 168 | } 169 | 170 | h4 { 171 | font-family: var(--code-zh); 172 | } 173 | } 174 | 175 | .blur-if-custom-background { 176 | @include blur-if-custom-background; 177 | } 178 | 179 | .blur-effect { 180 | @include blur-effect; 181 | } 182 | 183 | .raster-effect { 184 | @include raster-effect; 185 | } 186 | -------------------------------------------------------------------------------- /src/components/dropdown-menu.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | {{ menuItem.icon }} 13 | 14 | {{ menuItem.title }} 15 | {{ menuItem.innerText }} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 53 | 54 | 138 | -------------------------------------------------------------------------------- /src/lib/common/settings/setting-widgets/about.update.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ isLatest ? 'check' : 'warning' }} 5 | {{ isLatest ? '当前是最新版本' : '检测到新版本' }} 6 | 7 | 8 | 9 | {{ release?.name }} 10 | 预览版 11 | 12 | 13 | 14 | 15 | {{ release?.author.name }} 16 | 17 | 18 | 19 | 20 | 21 | 安装更新 23 | 24 | 25 | 26 | 27 | 28 | warning 29 | 请求过于频繁,请稍后重试 30 | 31 | 32 | 33 | 58 | 59 | 151 | -------------------------------------------------------------------------------- /src/stylesheets/tieba/tieba-main.scss: -------------------------------------------------------------------------------- 1 | /* 动画 */ 2 | /* 淡入动画 */ 3 | @keyframes animation-fade-in { 4 | 0% { 5 | opacity: 0; 6 | } 7 | 8 | 100% { 9 | opacity: 1; 10 | } 11 | } 12 | 13 | .fade-in-elem { 14 | animation: animation-fade-in ease 0.3s forwards; 15 | } 16 | 17 | // head 18 | #com_userbar { 19 | display: none; 20 | } 21 | 22 | /* 功能按钮 */ 23 | .tbui_aside_float_bar { 24 | border: none !important; 25 | background: none !important; 26 | } 27 | 28 | .tbui_aside_float_bar li { 29 | width: 40px; 30 | height: 40px; 31 | border-radius: 24px; 32 | margin: 8px 0; 33 | background-color: var(--light-background); 34 | } 35 | 36 | .tbui_aside_float_bar li:hover { 37 | background-color: var(--default-hover); 38 | } 39 | 40 | .tbui_aside_float_bar li:active { 41 | background-color: var(--default-active); 42 | } 43 | 44 | .tbui_aside_float_bar li a { 45 | @extend %icon; 46 | width: 40px !important; 47 | height: 40px !important; 48 | border-radius: 24px; 49 | background: none !important; 50 | } 51 | 52 | .tbui_aside_float_bar a { 53 | /* 功能按钮 svg 容器 */ 54 | width: 40px !important; 55 | height: 40px !important; 56 | color: var(--minimal-fore); 57 | font-size: 24px; 58 | line-height: 40px; 59 | text-align: center; 60 | /* background-size: 20px; 61 | background-repeat: no-repeat; 62 | background-position: center; 63 | filter: drop-shadow(var(--minimal-fore) 0 -9999px); 64 | transform: translateY(9999px); */ 65 | } 66 | 67 | /* .tbui_aside_float_bar a:hover { 68 | color: var(--default-background); 69 | filter: drop-shadow(var(--default-fore) 0 -9999px); 70 | } */ 71 | 72 | .tbui_aside_float_bar .tbui_fbar_auxiliaryCare a { 73 | /* 无障碍模式 */ 74 | height: 40px !important; 75 | background: none !important; 76 | } 77 | 78 | .tbui_fbar_auxiliaryCare a::after { 79 | content: "accessibility_new"; 80 | /* background-image: var(--svg-accessibility); */ 81 | } 82 | 83 | .tbui_fbar_top a::after { 84 | /* 回到顶部 */ 85 | /* color: var(--tieba-theme-fore); */ 86 | content: "arrow_upward"; 87 | /* background-image: var(--svg-arrow-up); 88 | filter: drop-shadow(var(--tieba-theme-fore) 0 -9999px); */ 89 | } 90 | 91 | /* .tbui_aside_float_bar .tbui_fbar_top a { 92 | background-color: var(--tieba-theme-background) !important; 93 | } */ 94 | 95 | /* .tbui_fbar_top a:hover::after { 96 | color: var(--default-background); 97 | } */ 98 | 99 | .tbui_fbar_post a::after { 100 | /* 回贴 */ 101 | /* color: var(--default-background); */ 102 | content: "chat"; 103 | font-size: 22px; 104 | /* vertical-align: bottom; */ 105 | /* background-image: var(--svg-message); 106 | filter: drop-shadow(var(--default-background) 0 -9999px); */ 107 | } 108 | 109 | /* .tbui_aside_float_bar .tbui_fbar_post a, 110 | .tbui_aside_float_bar .tbui_fbar_post a:hover { 111 | background-color: var(--tieba-theme-color) !important; 112 | } */ 113 | 114 | .tbui_fbar_feedback a::after { 115 | /* 反馈 */ 116 | content: "report"; 117 | font-size: 26px; 118 | /* background-image: var(--svg-infomation-outline); 119 | background-size: 24px; */ 120 | } 121 | 122 | .tbui_aside_float_bar li.tbui_fbar_feedback a { 123 | /* 部分吧反馈 */ 124 | background: none !important; 125 | } 126 | 127 | .tbui_aside_float_bar .tbui_fbar_feedback a, 128 | .tbui_aside_float_bar .tbui_fbar_feedback a:hover { 129 | background: none !important; 130 | } 131 | 132 | .tbui_aside_float_bar .tbui_fbar_down, 133 | .tbui_aside_float_bar .tbui_fbar_props, 134 | .tbui_aside_float_bar .tbui_fbar_tsukkomi, 135 | .tbui_aside_float_bar .tbui_fbar_share, 136 | .tbui_aside_float_bar .tbui_fbar_favor, 137 | .tbui_aside_float_bar .tbui_fbar_refresh { 138 | display: none; 139 | } 140 | 141 | /* 图片缩放控件 */ 142 | .p_tools a { 143 | padding: 0 10px; 144 | background: none; 145 | vertical-align: bottom; 146 | } 147 | 148 | .p_tools span { 149 | /* 分隔线 */ 150 | display: none; 151 | } 152 | 153 | .p_tools .p_putup::before, 154 | .p_tools .tb_icon_ypic::before, 155 | .p_tools .tb_icon_turnleft::before, 156 | .p_tools .tb_icon_turnright::before { 157 | margin-right: 4px; 158 | font-family: "Material Symbols", system-ui; 159 | font-size: 14px; 160 | vertical-align: bottom; 161 | } 162 | 163 | .p_tools .p_putup::before { 164 | /* 收起 */ 165 | content: "zoom_out"; 166 | } 167 | 168 | .p_tools .tb_icon_ypic::before { 169 | /* 查看大图 */ 170 | content: "zoom_out_map"; 171 | } 172 | 173 | .p_tools .tb_icon_turnleft::before { 174 | /* 左转 */ 175 | content: "turn_left"; 176 | } 177 | 178 | .p_tools .tb_icon_turnright::before { 179 | /* 右转 */ 180 | content: "turn_right"; 181 | } 182 | -------------------------------------------------------------------------------- /src/lib/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { GM_addStyle } from "$"; 2 | import "@/stylesheets/main/animations.scss"; 3 | import baseStyle from "@/stylesheets/main/base.scss?inline"; 4 | import "@/stylesheets/main/material-symbols.css"; 5 | import "@/stylesheets/main/palette.scss"; 6 | import universalStyle from "@/stylesheets/main/universal.scss?inline"; 7 | import "@/stylesheets/main/variables.scss"; 8 | import tiebaErrorStyle from "@/stylesheets/tieba/tieba-error.scss?inline"; 9 | import tiebaForumStyle from "@/stylesheets/tieba/tieba-forum.scss?inline"; 10 | import tiebaHomeStyle from "@/stylesheets/tieba/tieba-home.scss?inline"; 11 | import tiebaMainStyle from "@/stylesheets/tieba/tieba-main.scss?inline"; 12 | import tiebaThreadStyle from "@/stylesheets/tieba/tieba-thread.scss?inline"; 13 | import _ from "lodash"; 14 | import { getResource } from "../api/remixed"; 15 | import { domrd } from "../elemental"; 16 | import { injectCSSRule, overwriteCSS, parseMultiCSS } from "../elemental/styles"; 17 | import { scrollbarWidth } from "../render"; 18 | import { customBackground, customStyle, fontWeights, monospaceFonts, themeColor, userFonts, wideScreen } from "../user-values"; 19 | import { waitUntil } from "../utils"; 20 | import { hexToRGBA, rgbaToHSLA } from "../utils/color"; 21 | 22 | export const darkPrefers = matchMedia("(prefers-color-scheme: dark)"); 23 | 24 | /** 动态样式 */ 25 | export async function loadDynamicCSS() { 26 | const theme = themeColor.get(); 27 | const darkRGBA = hexToRGBA(theme.dark); 28 | const lightRGBA = hexToRGBA(theme.light); 29 | const darkHSLA = rgbaToHSLA(darkRGBA); 30 | const lightHSLA = rgbaToHSLA(lightRGBA); 31 | 32 | const dynCSS = parseMultiCSS({ 33 | ":root": { 34 | "--content-max": wideScreen.get().noLimit 35 | ? "100vw" 36 | : `${wideScreen.get().maxWidth}px`, 37 | "--code-zh": `${_.join(userFonts.get(), ",")}`, 38 | "--code-monospace": `${_.join(monospaceFonts.get(), ",")}`, 39 | "--font-weight-normal": `${fontWeights.get().normal}`, 40 | "--font-weight-bold": `${fontWeights.get().bold}`, 41 | }, 42 | 43 | "html.dark-theme": { 44 | "--tieba-theme-color": theme.dark, 45 | "--trans-tieba-theme-color": `rgb(${darkRGBA.r} ${darkRGBA.g} ${darkRGBA.b} / 80%)`, 46 | "--tieba-theme-hover": `hsl(${darkHSLA.h}deg ${parseInt(darkHSLA.s) + 40}% ${parseInt(darkHSLA.l) + 10}%)`, 47 | "--tieba-theme-active": `hsl(${darkHSLA.h}deg ${parseInt(darkHSLA.s) + 50}% ${parseInt(darkHSLA.l) + 20}%)`, 48 | "--tieba-theme-background": `rgb(${darkRGBA.r} ${darkRGBA.g} ${darkRGBA.b} / 24%)`, 49 | "--tieba-theme-fore": `hsl(${darkHSLA.h}deg 100% 75%)`, 50 | }, 51 | 52 | "html.light-theme": { 53 | "--tieba-theme-color": theme.light, 54 | "--trans-tieba-theme-color": `rgb(${lightRGBA.r} ${lightRGBA.g} ${lightRGBA.b} / 80%)`, 55 | "--tieba-theme-hover": `hsl(${lightHSLA.h}deg ${parseInt(lightHSLA.s) - 40}% ${parseInt(lightHSLA.l) - 10}%)`, 56 | "--tieba-theme-active": `hsl(${lightHSLA.h}deg ${parseInt(lightHSLA.s) - 50}% ${parseInt(lightHSLA.l) - 20}%)`, 57 | "--tieba-theme-background": `rgb(${lightRGBA.r} ${lightRGBA.g} ${lightRGBA.b} / 24%)`, 58 | "--tieba-theme-fore": `hsl(${lightHSLA.h}deg 60% 32%)`, 59 | }, 60 | }); 61 | 62 | GM_addStyle(dynCSS); 63 | 64 | window.addEventListener("load", function () { 65 | GM_addStyle( 66 | parseMultiCSS({ 67 | ":root": { 68 | "--scrollbar-width": `${scrollbarWidth()}px`, 69 | }, 70 | }) 71 | ); 72 | }, { once: true }); 73 | 74 | const customCSS = customStyle.get(); 75 | if (customCSS !== "") GM_addStyle(customCSS); 76 | } 77 | 78 | export async function loadMainCSS() { 79 | overwriteCSS( 80 | baseStyle, 81 | universalStyle, 82 | tiebaErrorStyle, 83 | tiebaForumStyle, 84 | tiebaHomeStyle, 85 | tiebaMainStyle, 86 | tiebaThreadStyle, 87 | ); 88 | 89 | document.addEventListener("DOMContentLoaded", function () { 90 | document.head.appendChild(domrd("link", { 91 | type: "image/icon", 92 | rel: "shortcut icon", 93 | href: getResource("/assets/images/main/favicon32.ico"), 94 | })); 95 | }, { once: true }); 96 | } 97 | 98 | let customBackgroundElement: Maybe = undefined; 99 | 100 | export async function setCustomBackground() { 101 | if (customBackgroundElement) { 102 | document.head.removeChild(customBackgroundElement); 103 | } 104 | customBackgroundElement = injectCSSRule("body.custom-background", { 105 | backgroundImage: `url('${customBackground.get()}') !important`, 106 | backgroundRepeat: "no-repeat !important", 107 | backgroundAttachment: "fixed !important", 108 | backgroundSize: "cover !important", 109 | }); 110 | 111 | waitUntil(() => !_.isNil(document.body)).then(function () { 112 | if (customBackground.get()) { 113 | document.body.classList.add("custom-background"); 114 | } else { 115 | document.body.classList.remove("custom-background"); 116 | } 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from "@vitejs/plugin-vue"; 2 | import vueJSX from "@vitejs/plugin-vue-jsx"; 3 | import deepmerge from "deepmerge"; 4 | import { resolve } from "path"; 5 | import postcssPresetEnv from "postcss-preset-env"; 6 | import { UserConfig, defineConfig } from "vite"; 7 | import monkey, { MonkeyOption, cdn, util } from "vite-plugin-monkey"; 8 | 9 | const scriptOptions: MonkeyOption = { 10 | entry: "src/main.ts", 11 | userscript: { 12 | name: "Tieba Remix", 13 | namespace: "https://github.com/HacksawBlade/Tieba-Remix", 14 | version: "0.4.7-beta", 15 | description: "贴吧网页端重塑", 16 | author: "锯条", 17 | license: "MIT", 18 | updateURL: "https://gitee.com/HacksawBlade/Tieba-Remix/raw/beta/build/tieba-remix.user.js", 19 | downloadURL: "https://gitee.com/HacksawBlade/Tieba-Remix/raw/beta/build/tieba-remix.user.js", 20 | icon: "https://gitee.com/HacksawBlade/Tieba-Remix/raw/master/assets/images/main/icon16.png", 21 | icon64: "https://gitee.com/HacksawBlade/Tieba-Remix/raw/master/assets/images/main/icon64.png", 22 | match: [ 23 | "*://tieba.baidu.com/", 24 | "*://tieba.baidu.com/index.*", 25 | "*://tieba.baidu.com/?*", 26 | "*://tieba.baidu.com/p/*", 27 | "*://tieba.baidu.com/f?*", 28 | "*://jump.bdimg.com/safecheck/*", 29 | "*://jump2.bdimg.com/safecheck/*", 30 | ], 31 | "run-at": "document-start", 32 | }, 33 | build: { 34 | externalGlobals: { 35 | "vue": cdn.jsdelivrFastly("Vue", "dist/vue.global.prod.js") 36 | .concat(util.dataUrl(";window.Vue=Vue;")), 37 | "marked": cdn.jsdelivrFastly("marked", "lib/marked.umd.min.js"), 38 | "lodash": cdn.jsdelivrFastly("_", "lodash.min.js"), 39 | "libelemental": cdn.jsdelivrFastly("libelemental", "build/index.min.js"), 40 | "user-view": cdn.jsdelivrFastly("user-view", "build/index.min.js"), 41 | }, 42 | }, 43 | }; 44 | 45 | const commonConfig = defineConfig({ 46 | build: { 47 | lib: { 48 | entry: "./src/main.ts", 49 | name: "TiebaRemix", 50 | formats: ["iife"], 51 | fileName: () => `tieba-remix.user.js`, 52 | }, 53 | outDir: "build", 54 | reportCompressedSize: false, 55 | cssCodeSplit: false, 56 | rollupOptions: { 57 | output: { 58 | globals: { 59 | "vue": "Vue", 60 | "marked": "marked", 61 | "lodash": "_", 62 | "libelemental": "libelemental", 63 | "user-view": "UserView", 64 | }, 65 | }, 66 | }, 67 | }, 68 | css: { 69 | preprocessorOptions: { 70 | scss: { 71 | additionalData: ` 72 | @use "@/stylesheets/modules/common" as *; 73 | @use "@/stylesheets/modules/animation-exports" as *;`, 74 | }, 75 | }, 76 | postcss: { 77 | plugins: [ 78 | postcssPresetEnv(), 79 | ], 80 | }, 81 | }, 82 | plugins: [ 83 | vue(), 84 | vueJSX({}), 85 | ], 86 | resolve: { 87 | alias: [ 88 | { 89 | find: "@", 90 | replacement: resolve(__dirname, "./src"), 91 | }, 92 | ], 93 | }, 94 | server: { 95 | proxy: { 96 | "/p": { 97 | target: "https://tieba.baidu.com", 98 | changeOrigin: true, 99 | }, 100 | "/f": { 101 | target: "https://tieba.baidu.com", 102 | changeOrigin: true, 103 | }, 104 | "/suggestion": { 105 | target: "https://tieba.baidu.com", 106 | changeOrigin: true, 107 | }, 108 | }, 109 | }, 110 | }); 111 | 112 | const devConfig = defineConfig({ 113 | build: { 114 | minify: false, 115 | cssMinify: false, 116 | }, 117 | plugins: [ 118 | monkey(scriptOptions), 119 | ], 120 | }); 121 | 122 | const forkConfig = defineConfig({ 123 | build: { 124 | minify: false, 125 | cssMinify: false, 126 | }, 127 | plugins: [ 128 | monkey(scriptOptions), 129 | ], 130 | }); 131 | 132 | const prodConfig = defineConfig({ 133 | build: { 134 | minify: "terser", 135 | cssMinify: true, 136 | terserOptions: { 137 | sourceMap: false, 138 | toplevel: true, 139 | compress: { 140 | pure_funcs: [ 141 | "console.log", 142 | "deb", 143 | ], 144 | }, 145 | }, 146 | }, 147 | plugins: [ 148 | monkey(scriptOptions), 149 | ], 150 | }); 151 | 152 | const viteConfig = { 153 | build: { 154 | "development": () => deepmerge(commonConfig, devConfig), 155 | "production": () => deepmerge(commonConfig, prodConfig), 156 | "fork": () => deepmerge(commonConfig, forkConfig), 157 | }, 158 | serve: { 159 | "development": () => deepmerge(commonConfig, devConfig), 160 | }, 161 | }; 162 | 163 | export default defineConfig(({ command, mode }) => { 164 | return viteConfig[command][mode](); 165 | }); 166 | -------------------------------------------------------------------------------- /src/lib/theme/page-extension/thread/parser.ts: -------------------------------------------------------------------------------- 1 | import { dom } from "@/lib/elemental"; 2 | import { TiebaForum } from "@/lib/tieba-components/forum"; 3 | import _ from "lodash"; 4 | 5 | export interface ThreadContent { 6 | post: HTMLDivElement; 7 | replyButton: HTMLAnchorElement; 8 | dataField: string; 9 | isLouzhu: boolean; 10 | 11 | profile: { 12 | avatar: HTMLAnchorElement; 13 | nameAnchor: HTMLAnchorElement; 14 | level: number; 15 | badgeTitle: string; 16 | } 17 | 18 | tail: { 19 | location: string; 20 | platform: string; 21 | floor: string; 22 | time: string; 23 | } 24 | } 25 | 26 | export interface TiebaThread { 27 | title: string; 28 | reply: number; 29 | pages: number; 30 | 31 | displayWrapper: HTMLDivElement; 32 | lzOnlyButton: HTMLAnchorElement; 33 | favorButton: HTMLAnchorElement; 34 | 35 | forum: TiebaForum; 36 | cotents: ThreadContent[]; 37 | 38 | pager: { 39 | listPager: HTMLLIElement; 40 | jumper: { 41 | textbox: HTMLInputElement; 42 | submitButton: HTMLButtonElement; 43 | } 44 | } 45 | } 46 | 47 | export interface PostDataField { 48 | author: { 49 | portrait: string; 50 | props: unknown; 51 | user_id: number; 52 | user_name: string; 53 | user_nickname: string; 54 | } 55 | 56 | content: { 57 | builderId: number; 58 | /** 评论数量 */ 59 | comment_num: number; 60 | /** 待解析的 HTML */ 61 | content: string; 62 | forum_id: number; 63 | isPlus: number; 64 | is_anonym: number; 65 | is_fold: number; 66 | pb_tpoint: unknown; 67 | post_id: number; 68 | /** 当前楼层在当前页的实际位置 */ 69 | post_index: number; 70 | /** 一般意义上的楼层数 */ 71 | post_no: number; 72 | props: unknown; 73 | thread_id: number; 74 | type: "0" 75 | } 76 | } 77 | 78 | export function threadParser(): TiebaThread { 79 | const postWrappers = dom<"div">(".l_post", []); 80 | const contents = dom<"div">(".d_post_content", []); 81 | const dAuthors = dom<"div">(".d_author", []); 82 | const avatars = dom<"a">(".p_author_face", []); 83 | const nameAnchors = dom<"a">(".p_author_name", []); 84 | const levels = dom<"div">(".d_badge_lv", []); 85 | const badgeTitles = dom<"div">(".d_badge_title", []); 86 | 87 | const replyButtons = dom<"a">(".lzl_link_unfold", []); 88 | 89 | const locations = _.map(dom<"span">(".post-tail-wrap span:first-child, .ip-location", []), el => el.innerText); 90 | const platforms = _.map(dom<"a">(".tail-info a, .p_tail_wap", []), el => el.innerText); 91 | const floors = _.map(dom<"span">(".j_jb_ele + .tail-info + .tail-info, .p_tail li:first-child span", []), el => el.innerText); 92 | const times = _.map(dom<"span">(".post-tail-wrap span:nth-last-child(2), .p_tail li:last-child span", []), el => el.innerText); 93 | 94 | const threadContents: ThreadContent[] = []; 95 | 96 | for (let i = 0; i < contents.length; i++) { 97 | contents[i].classList.add("floor-content"); 98 | avatars[i].classList.add("floor-avatar"); 99 | nameAnchors[i].classList.add("floor-name"); 100 | 101 | threadContents.push({ 102 | post: contents[i], 103 | replyButton: replyButtons[i], 104 | dataField: _.defaults(postWrappers[i].getAttribute("data-field"), ""), 105 | isLouzhu: !!dom(".louzhubiaoshi_wrap", dAuthors[i]), 106 | 107 | profile: { 108 | avatar: avatars[i], 109 | nameAnchor: nameAnchors[i], 110 | level: parseInt(levels[i].innerText), 111 | badgeTitle: badgeTitles[i].innerText, 112 | }, 113 | tail: { 114 | location: locations[i], 115 | platform: platforms[i], 116 | floor: floors[i], 117 | time: times[i], 118 | }, 119 | }); 120 | } 121 | 122 | const thread: TiebaThread = { 123 | displayWrapper: dom<"div">(".wrap2", [])[0], 124 | title: PageData.thread.title, 125 | reply: +(dom<"span">(".l_reply_num span:nth-child(1)")?.innerText ?? 0), 126 | pages: PageData.pager.total_page, 127 | lzOnlyButton: dom<"a">("#lzonly_cntn", [])[0], 128 | favorButton: dom<"a">(".j_favor", [])[0], 129 | 130 | cotents: threadContents, 131 | forum: { 132 | info: { 133 | name: PageData.forum.forum_name, 134 | // followersDisplay: DOMS(true, ".card_menNum", "span").innerText, 135 | // postsDisplay: DOMS(true, ".card_infoNum", "span").innerText, 136 | }, 137 | 138 | components: { 139 | nameAnchor: dom<"a">(".card_title_fname", [])[0], 140 | iconContainer: dom<"a">(".card_head a, .plat_picbox", [])[0], 141 | followButton: dom<"a">(".card_head .focus_btn", [])[0], 142 | signButton: dom<"a">(".j_sign_box", [])[0], 143 | }, 144 | }, 145 | pager: { 146 | listPager: dom<"li">(".pb_list_pager", [])[0], 147 | jumper: { 148 | textbox: dom<"input">(".jump_input_bright", [])[0], 149 | submitButton: dom<"button">(".jump_btn_bright", [])[0], 150 | }, 151 | }, 152 | }; 153 | 154 | return thread; 155 | } 156 | -------------------------------------------------------------------------------- /src/modules/shield/module.shield.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | {{ sh.scope === "content" ? "chat" : "account_circle" }} 10 | {{ sh.content }} 11 | 12 | delete 13 | 14 | 当前没有记录屏蔽规则 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 确定 25 | 26 | 27 | 28 | 29 | 30 | 31 | 93 | 94 | 172 | -------------------------------------------------------------------------------- /src/lib/render/index.tsx: -------------------------------------------------------------------------------- 1 | import { SupportedComponent } from "@/ex"; 2 | import { dom, domrd } from "@/lib/elemental"; 3 | import { CSSRule, injectCSSRule, parseCSSRule } from "@/lib/elemental/styles"; 4 | import _ from "lodash"; 5 | import { UserDialog, UserDialogAbnormal, UserDialogOpts } from "user-view"; 6 | import { App, Component, ComponentPublicInstance, createApp, h } from "vue"; 7 | 8 | export interface RenderedComponent { 9 | app: App; 10 | instance: ComponentPublicInstance; 11 | } 12 | 13 | export function renderComponent( 14 | root: Component, 15 | container: string | Element, 16 | rootProps?: T): RenderedComponent { 17 | const app = createApp(root, rootProps); 18 | return { 19 | app: app, 20 | instance: app.mount(container), 21 | }; 22 | } 23 | 24 | /** 获取垂直滚动条的宽度。对应的 CSS 变量为 `--scrollbar-width` */ 25 | export const scrollbarWidth = _.once(function () { 26 | // 仅在文档宽度不超过窗口宽度(或文档 overflow-x: hidden)时是正确的 27 | return window.innerWidth - document.documentElement.clientWidth; 28 | }); 29 | 30 | export function renderPage(root: Component, rootProps?: LiteralObject) { 31 | if (document.getElementsByTagName("body").length === 0) { 32 | document.documentElement.appendChild(domrd("body")); 33 | } 34 | 35 | removeDefault(); 36 | 37 | const page = domrd("div", { id: "remixed-page" }); 38 | document.body.insertBefore(page, document.body.firstChild); 39 | 40 | document.body.appendChild(domrd("div", { 41 | "id": "carousel_wrap", 42 | })); 43 | 44 | injectCSSRule("#spage-tbshare-container, .tbui_aside_float_bar", { 45 | display: "none !important", 46 | }); 47 | 48 | return renderComponent(root, page, rootProps); 49 | } 50 | 51 | export function createRenderWrapper(id: string, style?: CSSRule) { 52 | let wrapper = dom<"div">(`#${id}`); 53 | return () => { 54 | if (_.isNil(wrapper)) { 55 | wrapper = document.body.appendChild(domrd("div", { 56 | id, 57 | style: parseCSSRule(style ?? {} as CSSRule), 58 | })); 59 | return wrapper; 60 | } 61 | return wrapper; 62 | }; 63 | } 64 | 65 | export interface DialogEvents { 66 | beforeRender(): void, 67 | rendered(rendered: RenderedComponent): void, 68 | beforeUnload(rendered: RenderedComponent): void, 69 | unloaded(payload: PayloadType): void, 70 | abnormalUnload(abnormal: UserDialogAbnormal): void, 71 | } 72 | 73 | /** 74 | * 渲染对话框。只有以 `` 及其继承组件为唯一根节点的组件才能作为对话框被正确渲染。 75 | * @param content 对话框内容组件 76 | * @param opts 组件选项 77 | * @param events 对话框事件绑定 78 | * @returns 对话框组件实例 79 | */ 80 | export function renderDialog< 81 | ContentOpts extends LiteralObject, 82 | PayloadType = any, 83 | >( 84 | content: SupportedComponent, 85 | opts?: ContentOpts, 86 | events?: Partial>, 87 | ): RenderedComponent { 88 | events?.beforeRender?.(); 89 | 90 | const dialogWrapper = document.body.appendChild( 91 | domrd("div", { class: "dialog-wrapper" }) 92 | ); 93 | const dialogApp = createApp(content, { 94 | ...opts, 95 | onUnload(payload: PayloadType) { 96 | events?.beforeUnload?.(rendered); 97 | _unload(); 98 | events?.unloaded?.(payload); 99 | }, 100 | onAbnormalUnload(abnormal: UserDialogAbnormal) { 101 | _unload(); 102 | events?.abnormalUnload?.(abnormal); 103 | }, 104 | }); 105 | 106 | const rendered: RenderedComponent = { 107 | app: dialogApp, 108 | instance: dialogApp.mount(dialogWrapper), 109 | }; 110 | 111 | events?.rendered?.(rendered); 112 | return rendered; 113 | 114 | function _unload() { 115 | dialogApp.unmount(); 116 | if (dom("[aria-modal]", []).length === 0) { 117 | document.body.removeAttribute("no-scrollbar"); 118 | document.body.style.paddingRight = ""; 119 | } 120 | dialogWrapper.remove(); 121 | } 122 | } 123 | 124 | export function userDialog( 125 | content: SupportedComponent, 126 | dialogOpts?: UserDialogOpts, 127 | opts?: ContentOpts 128 | ) { 129 | return renderDialog({h(content, opts)}); 130 | } 131 | 132 | export function removeDefault() { 133 | _.forEach(document.head.children, (el) => { 134 | if (el && el.tagName.toUpperCase() === "LINK" 135 | && _.includes(el.getAttribute("href"), "static-common/style")) { 136 | el.remove(); 137 | } 138 | 139 | if (el && el.tagName.toUpperCase() === "SCRIPT" 140 | && _.includes(el.getAttribute("src"), "static-common/lib")) { 141 | el.remove(); 142 | } 143 | }); 144 | 145 | // document.getElementById("com_userbar")?.remove(); 146 | 147 | _.forEach(document.body.children, (el) => { 148 | if (el && el.tagName.toUpperCase() === "STYLE") { 149 | el.remove(); 150 | } 151 | 152 | if (el && el.tagName.toUpperCase() === "SCRIPT") { 153 | el.remove(); 154 | } 155 | 156 | if (el && el.tagName.toUpperCase() === "IFRAME") { 157 | el.remove(); 158 | } 159 | 160 | if (el && _.includes(el.className, "translatorExtension")) { 161 | el.remove(); 162 | } 163 | 164 | if (el && _.includes(el.className, "dialogJ")) { 165 | el.remove(); 166 | } 167 | }); 168 | } 169 | -------------------------------------------------------------------------------- /src/modules/tieba-tags/index.ts: -------------------------------------------------------------------------------- 1 | import { dom, domrd, findParent } from "@/lib/elemental"; 2 | import { threadCommentsObserver } from "@/lib/observers"; 3 | import _ from "lodash"; 4 | import "./stylesheet.css"; 5 | 6 | export default { 7 | id: "tieba-tags", 8 | name: "楼中楼标签", 9 | author: "锯条", 10 | version: "2.0.1", 11 | brief: "优化楼中楼浏览体验", 12 | description: `为楼中楼的楼主、层主等用户添加特殊标签`, 13 | scope: ["thread"], 14 | runAt: "loaded", 15 | entry: main, 16 | } as UserModule; 17 | 18 | function main(): void { 19 | const TAGGED = "is-tagged"; 20 | const TB_TAG = "tag-elem"; 21 | const MY_TAG = "tieba-tags-me"; 22 | const LZ_TAG = "tieba-tags-lz"; 23 | const CZ_TAG = "tieba-tags-cz"; 24 | 25 | const louzhu = PageData.thread.author; 26 | const myPortrait = PageData.user.portrait; 27 | const myUserName = PageData.user.user_name; 28 | 29 | let louzhuPortrait = getLouzhuPortrait(document); 30 | 31 | // 预处理 32 | (async () => { 33 | if (!louzhuPortrait) { 34 | const response = await fetch(location.href.split("?")[0], { 35 | mode: "cors", 36 | credentials: "include", 37 | }); 38 | 39 | if (response.ok) { 40 | await response.text().then((value) => { 41 | const fpDOC = new DOMParser().parseFromString(value, "text/html"); 42 | louzhuPortrait = getLouzhuPortrait(fpDOC); 43 | }); 44 | } 45 | } 46 | })().then(() => { 47 | // 开启监控 48 | threadCommentsObserver.addEvent(createTagsAll); 49 | }); 50 | 51 | function getLouzhuPortrait(doc: Document): string | undefined { 52 | const j_tags = doc.getElementsByClassName("j_louzhubiaoshi"); 53 | if (j_tags.length > 0) { 54 | const targetFloor = findParent(j_tags[0], "l_post_bright"); 55 | if (targetFloor) { 56 | const dataAttr = targetFloor.getAttribute("data-field"); 57 | if (dataAttr) { 58 | const dataField = JSON.parse(dataAttr); 59 | return _.split(dataField.author.portrait, "?")[0]; 60 | } 61 | } 62 | } 63 | return undefined; 64 | } 65 | 66 | function createTagsAll() { 67 | _.forEach(dom(".lzl_cnt .at", []), (elem) => { 68 | if (elem.classList.contains(TAGGED)) return; 69 | elem.classList.add(TAGGED); 70 | 71 | let isLouzhu = false; 72 | // let isCengzhu = false; 73 | let isMe = false; 74 | 75 | const username = elem.getAttribute("username"); 76 | 77 | // 自己 78 | if (userClassify(myUserName, myPortrait)) { 79 | isMe = true; 80 | addTag(elem, MY_TAG); 81 | } 82 | 83 | // 楼主,如果我是楼主则不显示楼主层主 84 | if (!isMe) { 85 | if (userClassify(louzhu, louzhuPortrait)) { 86 | isLouzhu = true; 87 | addTag(elem, LZ_TAG); 88 | } 89 | } 90 | 91 | // 层主,如果我/楼主是层主则不显示 92 | if (!isMe && !isLouzhu) { 93 | const floor = findParent(elem, "l_post_bright"); 94 | if (floor) { 95 | const cengzhuCard = floor.getElementsByClassName("p_author_name")[0]; 96 | const cengzhu = cengzhuCard.textContent; 97 | 98 | if (cengzhu) { 99 | if (elem.textContent === cengzhu) { 100 | // isCengzhu = true; 101 | addTag(elem, CZ_TAG); 102 | } 103 | } 104 | } 105 | } 106 | 107 | function userClassify(un: string, portrait?: string): boolean { 108 | if (username === un && un !== "") { 109 | return true; 110 | } else if (_.indexOf(["", " "], username) !== -1) { 111 | // 无法正常获取到 username 和 dataField 112 | const targetPortrait = elem.getAttribute("portrait"); 113 | if (targetPortrait && portrait) { 114 | if (targetPortrait === portrait) { 115 | return true; 116 | } 117 | } else { 118 | return dataClassify(); 119 | } 120 | } else if (!username) { 121 | return dataClassify(); 122 | } 123 | return false; 124 | 125 | function dataClassify() { 126 | const dataAttr = elem.getAttribute("data-field"); 127 | if (dataAttr) { 128 | const dataField = JSON.parse(dataAttr.replace(/'/g, "\"")); 129 | if (portrait) { 130 | if (dataField.id === portrait) { 131 | return true; 132 | } 133 | } else { 134 | if (dataField.un === un) { 135 | return true; 136 | } 137 | } 138 | } 139 | return false; 140 | } 141 | } 142 | }); 143 | 144 | function addTag(elem: Element, className: string) { 145 | elem.appendChild( 146 | domrd("div", { 147 | class: `${TB_TAG} ${className}`, 148 | }) 149 | ); 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/lib/tieba-components/float-bar.tsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { TiebaAbstract, TiebaComponent } from "../api/abstract"; 3 | import { dom, domrd } from "../elemental"; 4 | 5 | export const floatButtonMap = { 6 | "auxiliary": "tbui_fbar_auxiliaryCare", 7 | "down": "tbui_fbar_down", 8 | "post": "tbui_fbar_post", 9 | "props": "tbui_fbar_props", 10 | "tsukkomi": "tbui_fbar_tsukkomi", 11 | "share": "tbui_fbar_share", 12 | "favor": "tbui_fbar_favor", 13 | "feedback": "tbui_fbar_feedback", 14 | "top": "tbui_fbar_top", 15 | "other": "*", 16 | }; 17 | 18 | export class FloatBar extends TiebaComponent<"ul"> { 19 | /** 20 | * 获取当前页面的 float buttons 21 | * @returns FloatBarButton[] 22 | */ 23 | public buttons(): FloatButton[] { 24 | if (!this.get()) return []; 25 | return Array.from(dom<"li">(".tbui_aside_fbar_button", floatBar.get(), [])).map(el => ({ 26 | el: el, 27 | type: (function () { 28 | for (let i = 0; i < el.classList.length; i++) { 29 | const cls = el.classList[i]; 30 | if (!cls.includes("tbui_fbar_")) continue; 31 | 32 | const key = _.findKey(floatButtonMap, (value) => value === cls); 33 | if (key) { 34 | return key as FloatButtonKey; 35 | } 36 | } 37 | return "other"; 38 | })(), 39 | })); 40 | } 41 | 42 | public add( 43 | type: FloatButtonKey, event: (() => void), 44 | className?: string, icon?: string, index = 0 45 | ) { 46 | const anchor = domrd("a", { 47 | href: "javascript:;", 48 | }); 49 | 50 | const el = domrd("li", { 51 | class: "tbui_aside_fbar_button", 52 | }, [anchor]); 53 | 54 | // const el = 55 | // 56 | // 57 | // ; 58 | 59 | el.addEventListener("click", event); 60 | 61 | if (type !== "other") { 62 | el.classList.add(floatButtonMap[type]); 63 | } 64 | if (className) 65 | el.classList.add(className); 66 | floatBar.get().insertBefore(el, floatBar.get().children[index]); 67 | setFloatButtonIcon(anchor, icon); 68 | 69 | return { el: el, type: type } as FloatButton; 70 | 71 | function setFloatButtonIcon(el: HTMLAnchorElement, icon?: string) { 72 | el.classList.add("icon"); 73 | el.classList.add("tbui_aside_fbar_button"); 74 | el.innerHTML = icon ? icon : ""; 75 | } 76 | } 77 | 78 | public remove(className: string): void; 79 | public remove(index: number): void; 80 | 81 | public remove(param: string | number) { 82 | switch (typeof param) { 83 | case "string": { 84 | const el = dom<"li">(param, floatBar.get()); 85 | el?.remove(); 86 | break; 87 | } 88 | 89 | case "number": { 90 | const el = floatBar.get().children[param]; 91 | el.remove(); 92 | break; 93 | } 94 | 95 | default: 96 | break; 97 | } 98 | } 99 | } 100 | 101 | export type FloatButtonKey = keyof typeof floatButtonMap; 102 | 103 | export interface FloatButton extends TiebaAbstract { 104 | el: HTMLLIElement; 105 | type: FloatButtonKey; 106 | } 107 | 108 | /** 浮动栏 */ 109 | export const floatBar = new FloatBar(".tbui_aside_float_bar"); 110 | 111 | // /** 112 | // * 获取当前页面的 float buttons 113 | // * @returns FloatBarButton[] 114 | // */ 115 | // export function getFloatButtons(): FloatButton[] { 116 | // if (!floatBar) return []; 117 | // return Array.from(DOMS(".tbui_aside_fbar_button", "li", floatBar.get())).map(el => ({ 118 | // el: el, 119 | // type: (function () { 120 | // for (let i = 0; i < el.classList.length; i++) { 121 | // const cls = el.classList[i]; 122 | // if (!cls.includes("tbui_fbar_")) continue; 123 | 124 | // // 这类型用的可能是有点魔怔了 125 | // const key = findKey(floatButtonMap, (value) => value === cls); 126 | // if (key) { 127 | // return key as FloatButtonKey; 128 | // } 129 | // } 130 | // return "other"; 131 | // })(), 132 | // })); 133 | // } 134 | 135 | // export function addFloatButton( 136 | // type: FloatButtonKey, event: (() => void), 137 | // className?: string, icon?: string, index = 0 138 | // ) { 139 | // const anchor = createNewElement("a", { 140 | // href: "javascript:;", 141 | // }); 142 | 143 | // const el = createNewElement("li", { 144 | // class: "tbui_aside_fbar_button", 145 | // }, [anchor]); 146 | 147 | // el.addEventListener("click", event); 148 | 149 | // if (type !== "other") { 150 | // el.classList.add(`tbui_fbar_${floatButtonMap[type]}`); 151 | // } 152 | // el.classList.add(className ? className : ""); 153 | // floatBar.get().insertBefore(el, floatBar.get().children[index]); 154 | // setFloatButtonIcon(anchor, icon); 155 | 156 | // return { el: el, type: type } as FloatButton; 157 | 158 | // function setFloatButtonIcon(el: HTMLAnchorElement, icon?: string) { 159 | // el.style.fontFamily = SymbolFont; 160 | // el.style.fontVariationSettings = `"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 20`; 161 | // el.style.userSelect = "none"; 162 | // el.innerHTML = icon ? icon : ""; 163 | // el.style.fontSize = "24px"; 164 | // el.style.lineHeight = "40px"; 165 | // el.style.textAlign = "center"; 166 | // el.style.color = "rgb(200, 200, 200)"; 167 | // } 168 | // } 169 | 170 | // export function removeFloatButton(className: string) { 171 | // const el = DOMS(className, "li", floatBar.get())[0]; 172 | // el.remove(); 173 | // } 174 | -------------------------------------------------------------------------------- /src/lib/tieba-components/thread.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { transEmojiFromDOMString } from "../api/tieba"; 3 | import { dom } from "../elemental"; 4 | import { TiebaForum } from "./forum"; 5 | 6 | export interface ThreadContent { 7 | post: HTMLDivElement; 8 | replyButton: HTMLAnchorElement; 9 | dataField: string; 10 | isLouzhu: boolean; 11 | 12 | profile: { 13 | avatar: HTMLAnchorElement; 14 | nameAnchor: HTMLAnchorElement; 15 | level: number; 16 | badgeTitle: string; 17 | } 18 | 19 | tail: { 20 | location: string; 21 | platform: string; 22 | floor: string; 23 | time: string; 24 | } 25 | } 26 | 27 | export interface TiebaThread { 28 | title: string; 29 | reply: number; 30 | pages: number; 31 | 32 | displayWrapper: HTMLDivElement; 33 | lzOnlyButton: HTMLAnchorElement; 34 | favorButton: HTMLAnchorElement; 35 | 36 | forum: TiebaForum; 37 | cotents: ThreadContent[]; 38 | 39 | pager: { 40 | listPager: HTMLLIElement; 41 | jumper: { 42 | textbox: HTMLInputElement; 43 | submitButton: HTMLButtonElement; 44 | } 45 | } 46 | } 47 | 48 | export interface PostDataField { 49 | author: { 50 | portrait: string; 51 | props: unknown; 52 | user_id: number; 53 | user_name: string; 54 | user_nickname: string; 55 | } 56 | 57 | content: { 58 | builderId: number; 59 | /** 评论数量 */ 60 | comment_num: number; 61 | /** 待解析的 HTML */ 62 | content: string; 63 | forum_id: number; 64 | isPlus: number; 65 | is_anonym: number; 66 | is_fold: number; 67 | pb_tpoint: unknown; 68 | post_id: number; 69 | /** 当前楼层在当前页的实际位置 */ 70 | post_index: number; 71 | /** 一般意义上的楼层数 */ 72 | post_no: number; 73 | props: unknown; 74 | thread_id: number; 75 | type: "0" 76 | } 77 | } 78 | 79 | export function threadParser(doc: Document): TiebaThread; 80 | export function threadParser(html: string): TiebaThread; 81 | export function threadParser(param: Document | string): TiebaThread { 82 | let doc: Document; 83 | if (typeof param === "string") 84 | doc = new DOMParser().parseFromString(transEmojiFromDOMString(param), "text/html"); 85 | else 86 | doc = param; 87 | 88 | const postWrappers = dom<"div">(".l_post", doc.body, []); 89 | const contents = dom<"div">(".d_post_content", doc.body, []); 90 | const dAuthors = dom<"div">(".d_author", doc.body, []); 91 | const avatars = dom<"a">(".p_author_face", doc.body, []); 92 | const nameAnchors = dom<"a">(".p_author_name", doc.body, []); 93 | const levels = dom<"div">(".d_badge_lv", doc.body, []); 94 | const badgeTitles = dom<"div">(".d_badge_title", doc.body, []); 95 | 96 | const replyButtons = dom<"a">(".lzl_link_unfold", doc.body, []); 97 | 98 | const locations = _.map(dom<"span">(".post-tail-wrap span:first-child, .ip-location", doc.body, []), el => el.innerText); 99 | const platforms = _.map(dom<"a">(".tail-info a, .p_tail_wap", doc.body, []), el => el.innerText); 100 | const floors = _.map(dom<"span">(".j_jb_ele + .tail-info + .tail-info, .p_tail li:first-child span", doc.body, []), el => el.innerText); 101 | const times = _.map(dom<"span">(".post-tail-wrap span:nth-last-child(2), .p_tail li:last-child span", doc.body, []), el => el.innerText); 102 | 103 | const threadContents: ThreadContent[] = []; 104 | 105 | for (let i = 0; i < contents.length; i++) { 106 | contents[i].classList.add("floor-content"); 107 | avatars[i].classList.add("floor-avatar"); 108 | nameAnchors[i].classList.add("floor-name"); 109 | 110 | threadContents.push({ 111 | post: contents[i], 112 | replyButton: replyButtons[i], 113 | dataField: _.defaults(postWrappers[i].getAttribute("data-field"), ""), 114 | isLouzhu: !!dom(".louzhubiaoshi_wrap", dAuthors[i]), 115 | 116 | profile: { 117 | avatar: avatars[i], 118 | nameAnchor: nameAnchors[i], 119 | level: parseInt(levels[i].innerText), 120 | badgeTitle: badgeTitles[i].innerText, 121 | }, 122 | tail: { 123 | location: locations[i], 124 | platform: platforms[i], 125 | floor: floors[i], 126 | time: times[i], 127 | }, 128 | }); 129 | } 130 | 131 | const thread: TiebaThread = { 132 | displayWrapper: dom<"div">(".wrap2", doc.body, [])[0], 133 | title: PageData.thread.title, 134 | reply: +(dom<"span">(".l_reply_num span:nth-child(1)", doc.body)?.innerText ?? 0), 135 | pages: PageData.pager.total_page, 136 | lzOnlyButton: dom<"a">("#lzonly_cntn", doc.body, [])[0], 137 | favorButton: dom<"a">(".j_favor", doc.body, [])[0], 138 | 139 | cotents: threadContents, 140 | forum: { 141 | info: { 142 | name: PageData.forum.forum_name, 143 | // followersDisplay: DOMS(".card_menNum", "span", doc.body)[0].innerText, 144 | // postsDisplay: DOMS(".card_infoNum", "span", doc.body)[0].innerText, 145 | }, 146 | 147 | components: { 148 | nameAnchor: dom<"a">(".card_title_fname", doc.body, [])[0], 149 | iconContainer: dom<"a">(".card_head a, .plat_picbox", doc.body, [])[0], 150 | followButton: dom<"a">(".card_head .focus_btn", doc.body, [])[0], 151 | signButton: dom<"a">(".j_sign_box", doc.body, [])[0], 152 | }, 153 | }, 154 | pager: { 155 | listPager: dom<"li">(".pb_list_pager", doc.body, [])[0], 156 | jumper: { 157 | textbox: dom<"input">(".jump_input_bright", doc.body, [])[0], 158 | submitButton: dom<"button">(".jump_btn_bright", doc.body, [])[0], 159 | }, 160 | }, 161 | }; 162 | 163 | return thread; 164 | } 165 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { GM_info } from "$"; 2 | import _ from "lodash"; 3 | import { toast } from "user-view"; 4 | 5 | export function cookies(): LiteralObject; 6 | export function cookies(key: string): string | undefined; 7 | 8 | export function cookies(key?: string) { 9 | const cookieArray = document.cookie.split(";"); 10 | 11 | if (key) { 12 | return _.find(cookieArray, (cookie) => cookie.trim().startsWith(`${key}=`)); 13 | } else { 14 | const result: LiteralObject = {}; 15 | 16 | _.forEach(cookieArray, (cookie) => { 17 | const [key, value] = cookie.split("="); 18 | result[key.trim()] = value.trim(); 19 | }); 20 | 21 | return result; 22 | } 23 | } 24 | 25 | /** 26 | * 接口调用实现的共公有模板 27 | * @param api 需要调用的接口,理论上所有的 `Promise` 都是被接受的 28 | * @returns 该请求返回的 json 29 | */ 30 | export async function requestInstance(api: Promise): Promise { 31 | try { 32 | const response = await api; 33 | if (response.ok) { 34 | return await response.json(); 35 | } 36 | } catch (error) { 37 | toast({ 38 | message: errorMessage(error as Error), 39 | type: "error", 40 | duration: 6000, 41 | }); 42 | } 43 | } 44 | 45 | const modules: UserModule[] = []; 46 | 47 | export function AllModules() { 48 | return modules; 49 | } 50 | 51 | /** 52 | * 整合统一的错误信息 53 | * @param error 错误对象 54 | * @returns 整合后的错误信息 55 | */ 56 | export function errorMessage(error: Error) { 57 | const errBody = error.stack ? error.stack : error.message; 58 | return `${GM_info.script.name} ${GM_info.script.version}\n${errBody}`; 59 | } 60 | 61 | /** 62 | * 检测两数组是否有重合的部分 63 | * @param arr1 数组1 64 | * @param arr2 数组2 65 | * @returns 是否有重合 66 | */ 67 | export function checkDuplicate(arr1: Array, arr2: Array) { 68 | const set1 = new Set(arr1); 69 | const set2 = new Set(arr2); 70 | for (const el of set2) { 71 | if (set1.has(el)) 72 | return true; 73 | } 74 | return false; 75 | } 76 | 77 | /** 78 | * 依据相对于当前的时间偏移量生成时间戳 79 | * @param year 80 | * @param month 81 | * @param day 82 | * @param hours 83 | * @param minutes 84 | * @param seconds 85 | * @returns 时间戳 86 | */ 87 | export function spawnOffsetTS( 88 | year = 0, month = 0, day = 0, 89 | hours = 0, minutes = 0, seconds = 0) { 90 | const now = new Date(); 91 | const offset = new Date(now.getFullYear() + year, now.getMonth() + month, now.getDate() + day, 92 | now.getHours() + hours, now.getMinutes() + minutes, now.getSeconds() + seconds, 0); 93 | return offset.getTime(); 94 | } 95 | 96 | /** 97 | * 根据对象生成适用于 `GET` 请求的请求体 98 | * @param body 请求体 99 | * @returns 适用于 `GET` 请求的请求体 100 | */ 101 | export function requestBody(body: LiteralObject) { 102 | let reqBody = ""; 103 | _.forOwn(body, (value, key) => { 104 | if (value === null || value === undefined) value = ""; 105 | reqBody += `${key}=${value}&`; 106 | }); 107 | return reqBody.slice(0, -1); 108 | } 109 | 110 | /** 111 | * 等待条件函数为真时再执行操作 112 | * @param condition 条件函数 113 | * @param timeout 超时时限,单位:毫秒,默认无限等待 114 | * @returns 115 | */ 116 | export function waitUntil(pred: (() => boolean), timeout = Infinity) { 117 | return new Promise((resolve, reject) => { 118 | const startTime = performance.now(); 119 | let id = -1; 120 | 121 | function tick() { 122 | if (pred()) { 123 | cancelAnimationFrame(id); 124 | resolve(); 125 | } else if (performance.now() - startTime >= timeout) { 126 | cancelAnimationFrame(id); 127 | reject(new Error("等待超时")); 128 | 129 | if (GM_info.script.version === "developer-only") { 130 | console.error(`等待超时,该函数未在指定时间内得到期望值:${pred}`); 131 | console.trace("发生错误的调用者:"); 132 | } 133 | } else { 134 | id = requestAnimationFrame(tick); 135 | } 136 | } 137 | 138 | id = requestAnimationFrame(tick); 139 | }); 140 | } 141 | 142 | export function isLiteralObject(obj: any): boolean { 143 | return obj && typeof obj === "object" && !Array.isArray(obj); 144 | } 145 | 146 | export function outputFile(filename: string, content: string) { 147 | const blob = new Blob([content], { type: "text/plain" }); 148 | const url = URL.createObjectURL(blob); 149 | const link = document.createElement("a"); 150 | 151 | link.href = url; 152 | link.download = filename; 153 | link.click(); 154 | URL.revokeObjectURL(url); 155 | } 156 | 157 | export async function selectLocalFile>( 158 | mode: "text" | "base64" = "text" 159 | ): Promise { 160 | return new Promise((resolve, reject) => { 161 | const input = document.createElement("input"); 162 | input.type = "file"; 163 | 164 | input.addEventListener("change", function () { 165 | if (!input.files) return; 166 | const file = input.files[0]; 167 | const reader = new FileReader(); 168 | 169 | reader.addEventListener("loadend", function () { 170 | const base64String = reader.result; 171 | resolve(base64String as T); 172 | }); 173 | 174 | reader.addEventListener("error", function () { 175 | reject(new Error()); 176 | }); 177 | 178 | switch (mode) { 179 | case "text": { 180 | reader.readAsText(file); 181 | break; 182 | } 183 | 184 | case "base64": { 185 | reader.readAsDataURL(file); 186 | break; 187 | } 188 | } 189 | }); 190 | 191 | input.click(); 192 | }); 193 | } 194 | 195 | /** 196 | * 判断一个集合是否为另一个集合的超集 197 | * @param superset 超集 198 | * @param subset 子集 199 | * @returns 若 参数 1 为 参数 2 的超集,返回 `true` 200 | */ 201 | export function isSuperset(superset: Set, subset: Set) { 202 | return _.every([...subset], element => superset.has(element)); 203 | } 204 | -------------------------------------------------------------------------------- /src/lib/theme/page-extension/thread/comments.scss: -------------------------------------------------------------------------------- 1 | $lzl-content-font-size: 15px; 2 | $lzl-tail-font-size: 13px; 3 | $lzl-content-margin: 12px; 4 | 5 | // 楼层尾巴 + 楼中楼 6 | .core_reply { 7 | margin-right: unset; 8 | 9 | // 楼中楼包装(包括 padding) 10 | .core_reply_wrapper { 11 | width: auto; 12 | max-width: 840px; // TODO: 更具体的调整 13 | border: none !important; 14 | background-color: unset !important; 15 | 16 | // 楼中楼容器 17 | .core_reply_content { 18 | 19 | // 单个楼中楼内容 20 | .lzl_single_post { 21 | margin-bottom: $lzl-content-margin; 22 | animation: kf-slide-in var(--default-duration); 23 | transform-origin: bottom; 24 | 25 | &:not(.first_no_border) { 26 | padding-top: 0; 27 | margin-top: 0; 28 | } 29 | 30 | // 当前具体内容 31 | .lzl_cnt { 32 | .at { 33 | @extend %anchor; 34 | padding: 2px 0; 35 | color: var(--default-fore); 36 | font-size: 14px; 37 | font-weight: var(--font-weight-bold); 38 | } 39 | 40 | .lzl_content_main { 41 | font-size: $lzl-content-font-size; 42 | 43 | a { 44 | @extend %anchor; 45 | } 46 | 47 | img { 48 | vertical-align: text-bottom; 49 | } 50 | } 51 | 52 | // 楼中楼尾巴 53 | .lzl_content_reply { 54 | display: flex; 55 | align-items: center; 56 | font-size: $lzl-tail-font-size; 57 | line-height: 28px; 58 | text-align: justify; 59 | 60 | // 楼中楼管理 61 | .lzl_op_list { 62 | color: var(--light-fore); 63 | 64 | a { 65 | @extend %anchor-noback; 66 | color: var(--light-fore); 67 | } 68 | } 69 | 70 | // 楼中楼举报 71 | .lzl_jb { 72 | order: 1; 73 | margin-left: auto; 74 | color: var(--light-fore); 75 | 76 | .lzl_jb_in { 77 | @extend %anchor-noback; 78 | padding: 0; 79 | } 80 | } 81 | 82 | // 楼中楼回复 83 | .lzl_s_r { 84 | @extend %anchor-noback; 85 | padding: 0; 86 | margin-left: 8px; 87 | color: var(--light-fore); 88 | } 89 | } 90 | } 91 | } 92 | 93 | // 楼中楼翻页 94 | .lzl_li_pager { 95 | padding: 0; 96 | margin-top: -$lzl-content-margin; 97 | 98 | // 点击查看(更多) 99 | .lzl_more { 100 | font-size: $lzl-tail-font-size; 101 | 102 | a { 103 | @extend %anchor; 104 | } 105 | } 106 | 107 | .j_pager { 108 | display: flex; 109 | align-items: center; 110 | font-family: var(--code-zh); 111 | font-size: $lzl-tail-font-size; 112 | 113 | .tP { 114 | width: auto; 115 | color: var(--tieba-theme-fore); 116 | } 117 | 118 | a { 119 | @extend %anchor; 120 | color: var(--light-fore); 121 | 122 | &:hover { 123 | color: var(--tieba-theme-fore); 124 | } 125 | } 126 | } 127 | } 128 | 129 | // 楼中楼发帖按钮 130 | .btn-sub { 131 | @extend %anchor; 132 | padding: 4px 0; 133 | border-radius: 0; 134 | background: none; 135 | font-size: $lzl-tail-font-size; 136 | } 137 | 138 | // 楼中楼编辑器 139 | .edui-container { 140 | width: auto !important; 141 | max-height: 64px; 142 | 143 | .edui-editor-body { 144 | overflow: hidden; 145 | height: max-content !important; 146 | max-height: 72px; 147 | padding: 6px; 148 | border-radius: 6px; 149 | 150 | .edui-body-container { 151 | min-height: 16px !important; 152 | max-height: 64px; 153 | padding-left: 0; 154 | border-radius: 6px; 155 | font-size: 14px; 156 | overflow-y: auto; 157 | } 158 | } 159 | } 160 | 161 | // 楼中楼编辑异常文本 162 | .lzl_panel_error { 163 | color: var(--error-color); 164 | } 165 | 166 | .lzl_panel_wrapper { 167 | width: 100%; 168 | 169 | .lzl_insertsmiley_holder, 170 | .lzl_panel_submit { 171 | @extend %anchor; 172 | width: max-content; 173 | height: max-content; 174 | padding: 0 8px; 175 | background: none; 176 | font-size: 12px; 177 | } 178 | 179 | .lzl_panel_smile { 180 | width: max-content; 181 | height: max-content; 182 | margin: 0; 183 | 184 | .lzl_insertsmiley_holder::after { 185 | content: "😊表情"; 186 | } 187 | } 188 | } 189 | } 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/components/feeds-masonry.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 182 | 183 | 221 | -------------------------------------------------------------------------------- /src/modules/notrans-emojis/index.ts: -------------------------------------------------------------------------------- 1 | import { dom } from "@/lib/elemental"; 2 | import { forumThreadsObserver, legacyIndexFeedsObserver, threadCommentsObserver } from "@/lib/observers"; 3 | import _ from "lodash"; 4 | 5 | export default { 6 | id: "notrans-emojis", 7 | name: "别动我的 emoji😠", 8 | author: "锯条", 9 | version: "1.0", 10 | brief: "拒绝替换我的 emoji", 11 | description: "原版贴吧会将部分emoji表情替换为旧版,该模块会让这些emoji重新跟随系统样式", 12 | scope: true, 13 | runAt: "afterHead", 14 | entry: main, 15 | } as UserModule; 16 | 17 | function main() { 18 | // 隐藏旧的 emoji 19 | // injectCSSRule(".nicknameEmoji", { 20 | // display: "none" 21 | // }); 22 | 23 | // 从 a标签提取emoji index 24 | const indexRegExp = /(?<=nickemoji\/).*?(?=.png)/gi; 25 | 26 | // 原 emoji 27 | const emojis = [ 28 | "º", "\u25CE", "\u25AB", "\u25C6", "\u2664", "\u2640", 29 | "\u2642", "\u10DA", "\u266C", "\u261E", "\u261C", "\u2706", "\u260E", 30 | "\u264B", "\u03A9", "\u2103", "\u2109", "\uD83D\uDE04", "\uD83D\uDE0D", 31 | "\uD83D\uDE18", "\uD83D\uDE1A", "\uD83D\uDE1C", "\uD83D\uDE33", "\uD83D\uDE01", 32 | "\uD83D\uDE1E", "\uD83D\uDE22", "\uD83D\uDE02", "\uD83D\uDE2B", "\uD83D\uDE28", 33 | "\uD83D\uDE31", "\uD83D\uDE21", "\uD83D\uDE37", "\uD83D\uDE32", "\uD83D\uDE08", 34 | "\uD83D\uDC37", "\uD83D\uDC36", "\uD83D\uDC11", "\uD83D\uDC35", "\uD83D\uDC28", 35 | "\uD83D\uDC34", "\uD83D\uDC3C", "\uD83D\uDC2F", "\uD83C\uDF6A", "\uD83C\uDF7A", 36 | "\uD83C\uDF66", "\uD83C\uDF6D", "\uD83C\uDF57", "\uD83C\uDF7C", "\uD83D\uDD2F", 37 | "\uD83C\uDF52", "\uD83D\uDC40", "\uD83D\uDC2D", "\uD83D\uDE07", "\uD83D\uDE3A", 38 | "\uD83D\uDE3B", "\uD83D\uDE40", "\uD83D\uDE3F", "\uD83D\uDE39", "\uD83D\uDE3E", 39 | "\uD83D\uDC79", "\uD83D\uDC7A", "\uD83C\uDF1E", "\uD83C\uDF1D", "\uD83C\uDF1A", 40 | "\uD83C\uDF1C", "\uD83C\uDF1B", "\uD83D\uDC66", "\uD83D\uDC67", "\uD83C\uDF8E", 41 | "\uD83C\uDF38", "\uD83C\uDF40", "\uD83C\uDF39", "\uD83C\uDF3B", "\uD83C\uDF3A", 42 | "\uD83C\uDF41", "\uD83C\uDF3F", "\uD83C\uDF44", "\uD83C\uDF35", "\uD83C\uDF34", 43 | "\uD83C\uDF33", "\uD83C\uDF30", "\uD83C\uDF31", "\uD83C\uDF3C", "\uD83C\uDF10", 44 | "\uD83C\uDF19", "\uD83C\uDF0B", "\uD83C\uDF0C", "\u26C5", "\u26A1", "\u2614", 45 | "\u26C4", "\uD83C\uDF00", "\uD83C\uDF08", "\uD83C\uDF0A", "\uD83D\uDD25", "\u2728", 46 | "\uD83C\uDF1F", "\uD83D\uDCA5", "\uD83D\uDCAB", "\uD83D\uDCA2", "\uD83D\uDCA6", 47 | "\uD83D\uDCA7", "\uD83D\uDCA4", "\uD83D\uDCA8", "\uD83C\uDF80", "\uD83C\uDF02", 48 | "\uD83D\uDC84", "\uD83D\uDC95", "\uD83D\uDC96", "\uD83D\uDC9E", "\uD83D\uDC98", 49 | "\uD83D\uDC8C", "\uD83D\uDC8B", "\uD83D\uDC9D", "\uD83C\uDF92", "\uD83C\uDF93", 50 | "\uD83C\uDF8F", "\uD83C\uDF83", "\uD83D\uDC7B", "\uD83C\uDF85", "\uD83C\uDF84", 51 | "\uD83C\uDF81", "\uD83D\uDE48", "\uD83D\uDC12", "\uD83D\uDCAF", "\uD83D\uDC6F", 52 | "\uD83D\uDC8D", 53 | ]; 54 | 55 | // 被替换的 emoji 56 | const transformed = [ 57 | "1-1.png", "1-2.png", "1-4.png", "1-5.png", "1-6.png", "1-7.png", "1-8.png", 58 | "1-9.png", "1-10.png", "1-11.png", "1-12.png", "1-13.png", "1-14.png", "1-15.png", 59 | "1-16.png", "1-17.png", "1-18.png", "1-19.png", "1-20.png", "1-21.png", "1-22.png", 60 | "1-23.png", "1-24.png", "1-25.png", "1-26.png", "1-27.png", "1-28.png", "1-29.png", 61 | "1-30.png", "1-31.png", "1-32.png", "1-33.png", "1-34.png", "1-35.png", "2-1.png", 62 | "2-2.png", "2-3.png", "2-4.png", "2-5.png", "2-6.png", "2-7.png", "2-8.png", "2-9.png", 63 | "2-10.png", "2-11.png", "2-12.png", "2-13.png", "2-14.png", "2-15.png", "2-16.png", 64 | "2-17.png", "2-18.png", "2-19.png", "2-20.png", "2-21.png", "2-22.png", "2-23.png", 65 | "2-24.png", "2-25.png", "2-26.png", "2-27.png", "2-28.png", "2-29.png", "2-30.png", 66 | "2-31.png", "2-32.png", "2-33.png", "2-34.png", "2-35.png", "3-1.png", "3-2.png", 67 | "3-3.png", "3-4.png", "3-5.png", "3-6.png", "3-7.png", "3-8.png", "3-9.png", 68 | "3-10.png", "3-11.png", "3-12.png", "3-13.png", "3-14.png", "3-15.png", "3-16.png", 69 | "3-17.png", "3-18.png", "3-19.png", "3-20.png", "3-21.png", "3-22.png", "3-23.png", 70 | "3-24.png", "3-25.png", "3-26.png", "3-27.png", "3-28.png", "3-29.png", "3-30.png", 71 | "3-31.png", "3-32.png", "3-33.png", "3-34.png", "3-35.png", "4-1.png", "4-2.png", 72 | "4-3.png", "4-4.png", "4-5.png", "4-6.png", "4-7.png", "4-8.png", "4-9.png", "4-10.png", 73 | "4-11.png", "4-12.png", "4-13.png", "4-14.png", "4-15.png", "4-16.png", "4-17.png", 74 | "4-18.png", "4-19.png", "4-20.png", "4-21.png", "4-22.png", "4-23.png", 75 | ]; 76 | 77 | // 看贴页面 78 | threadCommentsObserver.addEvent(() => { 79 | try { 80 | _.forEach(dom(` 81 | .p_author_name:has(.nicknameEmoji), 82 | .at:has(.nicknameEmoji), 83 | .lzl_content_main:has(.nicknameEmoji) 84 | `, []), (el) => { 85 | updateEmojis(el); 86 | }); 87 | } catch (error) { 88 | _.forEach(dom(".p_author_name, .at, .lzl_content_main", []), (el) => { 89 | if (_.includes(el.classList, "nicknameEmoji")) { 90 | updateEmojis(el); 91 | } 92 | }); 93 | } 94 | }); 95 | 96 | // 首页 97 | legacyIndexFeedsObserver.addEvent(() => { 98 | try { 99 | _.forEach(dom(` 100 | .new_list .post_author:has(.nicknameEmoji), 101 | .userinfo_username:has(.nicknameEmoji) 102 | `, []), (el) => { 103 | updateEmojis(el); 104 | }); 105 | } catch (error) { 106 | _.forEach(dom(".newlist .post_author, .userinfo_username", []), (el) => { 107 | if (_.includes(el.classList, "nicknameEmoji")) { 108 | updateEmojis(el); 109 | } 110 | }); 111 | } 112 | }); 113 | 114 | // 进吧页面 115 | forumThreadsObserver.addEvent(() => { 116 | try { 117 | _.forEach(dom(".threadlist_author a:has(.nicknameEmoji)", []), (el) => { 118 | updateEmojis(el); 119 | }); 120 | } catch (error) { 121 | _.forEach(dom(".threadlist_author a", []), (el) => { 122 | if (_.includes(el.classList, "nicknameEmoji")) { 123 | updateEmojis(el); 124 | } 125 | }); 126 | } 127 | }); 128 | 129 | function updateEmojis(elem: Element) { 130 | const arrIndex = elem.innerHTML.match(indexRegExp); 131 | arrIndex?.forEach(index => { 132 | const emoji = emojis[transformed.indexOf(`${index}.png`)]; 133 | const arrInner = elem.innerHTML.split(RegExp( 134 | `]*?${index}.png` + `(?:[^>]*?)*>`, "g" 135 | )); 136 | elem.innerHTML = arrInner.join(decodeURIComponent(emoji)); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/components/pager.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1 6 | 9 | keyboard_double_arrow_left 10 | 11 | 12 | 15 | {{ displayNumber }} 16 | 17 | 18 | 21 | keyboard_double_arrow_right 22 | 23 | 25 | {{ total }} 26 | 27 | 28 | | 29 | 30 | 31 | 转到 32 | 34 | 35 | 页 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 141 | 142 | 207 | -------------------------------------------------------------------------------- /src/lib/elemental/index.ts: -------------------------------------------------------------------------------- 1 | import { isLiteralObject, waitUntil } from "@/lib/utils"; 2 | import _ from "lodash"; 3 | 4 | export const fadeInElems: string[] = []; 5 | const fadeInClass = "fade-in-elem"; 6 | 7 | export interface DOMTagNameMap extends HTMLElementTagNameMap { 8 | "default": Element; 9 | } 10 | export type DOMTagNames = keyof DOMTagNameMap; 11 | 12 | /** 13 | * 使用选择器获取 DOM 元素 14 | * @param selector 选择器 15 | * @param parent 查找范围 16 | * @returns DOM 元素 17 | */ 18 | export function dom(selector: string, parent?: Element): Maybe; 19 | /** 20 | * @param selector 选择器 21 | * @param multi 查找全部 22 | */ 23 | export function dom(selector: string, multi?: never[]): DOMTagNameMap[T][]; 24 | /** 25 | * @param selector 选择器 26 | * @param parent 查找范围 27 | * @param multi 查找全部 28 | */ 29 | export function dom(selector: string, parent: Element, multi?: never[]): DOMTagNameMap[T][]; 30 | 31 | export function dom( 32 | selector: string, 33 | arg1?: Element | never[], 34 | arg2?: never[] 35 | ): Maybe { 36 | if (!arg1) { 37 | return document.querySelector(selector) ?? undefined; 38 | } 39 | if (Array.isArray(arg1)) { 40 | return Array.from(document.querySelectorAll(selector)); 41 | } 42 | if (!arg2) { 43 | return arg1.querySelector(selector) ?? undefined; 44 | } 45 | return Array.from(arg1.querySelectorAll(selector)); 46 | } 47 | 48 | /** 49 | * 等待 DOM 元素出现并获取 50 | * @param selector 选择器 51 | * @param parent 查找范围 52 | * @returns DOM 元素 53 | */ 54 | export function asyncdom( 55 | selector: string, 56 | parent?: Element 57 | ): Promise; 58 | /** 59 | * @param selector 选择器 60 | * @param parent 查找范围 61 | * @param timeout 超时限制,默认永远等待 62 | * @returns DOM 元素 63 | */ 64 | export function asyncdom( 65 | selector: string, 66 | parent?: Element, 67 | timeout?: number 68 | ): Promise>; 69 | 70 | export async function asyncdom( 71 | selector: string, 72 | parent?: Element, 73 | timeout = Infinity 74 | ) { 75 | return waitUntil(() => !_.isNil(dom(selector, parent)), timeout) 76 | .then(() => dom(selector, parent)); 77 | } 78 | 79 | /** 80 | * 让函数等待 `head` 标签可操作后执行。若当前已可操作 `head` 则会立即执行 81 | * @param callbackfn 回调函数 82 | */ 83 | export function afterHead(callbackfn: () => void) { 84 | // return new Promise((resolve, reject) => { 85 | // try { 86 | // const head = document.head; 87 | // if (head) { 88 | // callbackfn(); 89 | // resolve(); 90 | // } 91 | // } catch (error) { 92 | // reject(error); 93 | // } 94 | // }); 95 | callbackfn(); 96 | } 97 | 98 | /** 99 | * 获取某节点所有属性值 100 | * @param node 目标节点 101 | * @returns 包含该节点所有属性值的对象 102 | */ 103 | export function getNodeAttrs(node: HTMLElement) { 104 | const attrs = node.attributes; 105 | const des: LiteralObject = {}; 106 | 107 | for (const attr of attrs) { 108 | des[attr.name] = attr.value; 109 | } 110 | 111 | return des; 112 | } 113 | 114 | /** 115 | * 获取某节点所有属性值,同时解析属性值中的对象 116 | * @param node 目标节点 117 | * @returns 包含该节点所有属性值的对象 118 | */ 119 | export function getNodeAttrsDeeply(node: HTMLElement) { 120 | const attrs = node.attributes; 121 | const des: LiteralObject = {}; 122 | 123 | for (const attr of attrs) { 124 | if (typeof attr.value === "string") { 125 | try { 126 | const obj = JSON.parse(attr.value); 127 | if (isLiteralObject(obj)) { 128 | des[attr.name] = obj; 129 | } 130 | } catch (error) { 131 | des[attr.name] = attr.value; 132 | } 133 | } else { 134 | des[attr.name] = attr.value; 135 | } 136 | } 137 | 138 | return des; 139 | } 140 | 141 | /** 142 | * 将一个属性对象与目标节点的属性进行合并 143 | * @param node 目标节点 144 | * @param attrs 待合并的属性对象 145 | * @returns 合并后的节点属性对象 146 | */ 147 | export function mergeNodeAttrs( 148 | node: T, attrs: LiteralObject, 149 | ) { 150 | _.forOwn(attrs, (value, key) => { 151 | if (value !== node.getAttribute(key)) { 152 | if (isLiteralObject(value)) { 153 | node.setAttribute(key, JSON.stringify(attrs[key])); 154 | } else { 155 | node.setAttribute(key, attrs[key]); 156 | } 157 | } 158 | }); 159 | } 160 | 161 | /** 162 | * 将一个属性对象与目标节点的属性进行深度合并 163 | * @param node 目标节点 164 | * @param attrs 待合并的属性对象 165 | * @returns 合并后的节点属性对象 166 | */ 167 | export function mergeNodeAttrsDeeply( 168 | node: T, attrs: LiteralObject, 169 | ) { 170 | const src = getNodeAttrsDeeply(node); 171 | const des = _.merge(src, attrs); 172 | mergeNodeAttrs(node, des); 173 | } 174 | 175 | /** 176 | * 创建一个新节点 177 | * @param tag 待创建节点的标签 178 | * @param attrs 该节点的属性值 179 | * @param doc 从哪个 `Document` 创建节点 180 | * @returns 被创建的节点 181 | */ 182 | export function domrd( 183 | tag: T, attrs?: LiteralObject, children: (Node | string)[] | string = [], doc?: Document, 184 | ): HTMLElementTagNameMap[T] { 185 | const DOC = doc ? doc : document; 186 | const elem = DOC.createElement(tag); 187 | 188 | if (attrs) { 189 | mergeNodeAttrs(elem, attrs); 190 | } 191 | 192 | if (typeof children === "string") { 193 | elem.appendChild(document.createTextNode(children)); 194 | } else { 195 | _.forEach(children, child => { 196 | if (typeof child === "string") { 197 | elem.appendChild(document.createTextNode(child)); 198 | } else { 199 | elem.appendChild(child); 200 | } 201 | }); 202 | } 203 | 204 | return elem; 205 | } 206 | 207 | /** 208 | * 根据特征查找父元素 209 | * @param el 子元素 210 | * @param trait 父元素特征 211 | * @param mode 查找模式。默认按类名查找 212 | * @returns 符合条件的父元素 | `undefined` 213 | */ 214 | export function findParent( 215 | el: Element, 216 | trait: string, 217 | mode: "selector" | "className" | "id" | "tagName" = "className", 218 | ): Maybe { 219 | const verifier = ((): (parent: HTMLElement) => boolean => { 220 | switch (mode) { 221 | case "selector": { 222 | const allValid = new Set(dom(trait, [])); 223 | return (parent: HTMLElement) => { 224 | return allValid.has(parent); 225 | }; 226 | } 227 | 228 | case "className": { 229 | return (parent: HTMLElement) => parent.classList.contains(trait) ?? false; 230 | } 231 | 232 | case "id": { 233 | return (parent: HTMLElement) => parent.id === trait; 234 | } 235 | 236 | case "tagName": { 237 | return (parent: HTMLElement) => parent.tagName.toLowerCase() === trait.toLowerCase(); 238 | } 239 | } 240 | })(); 241 | 242 | while (el.parentElement && !verifier(el.parentElement)) { 243 | el = el.parentElement; 244 | } 245 | return el.parentElement ? el.parentElement as HTMLElementTagNameMap[T] : undefined; 246 | } 247 | 248 | /** 249 | * 元素淡入动画 250 | * @param selector QuerySelector 字符串 251 | */ 252 | export function fadeInLoad(selector: string) { 253 | dom<"div">(selector, []).forEach(elem => { 254 | elem.classList.add(fadeInClass); 255 | elem.addEventListener("animationend", () => { 256 | elem.style.opacity = "1"; 257 | elem.classList.remove(fadeInClass); 258 | }); 259 | }); 260 | } 261 | -------------------------------------------------------------------------------- /src/lib/api/remixed.tsx: -------------------------------------------------------------------------------- 1 | import { GM_getValue, GM_info, GM_listValues, GM_openInTab, GM_setValue } from "$"; 2 | import { GiteeRelease, GiteeReleaseNotFound, GiteeRepo, Owner, RepoName, ignoredTag, latestRelease, showUpdateToday, themeType, updateConfig } from "@/lib/user-values"; 3 | import { outputFile, selectLocalFile, spawnOffsetTS, waitUntil } from "@/lib/utils"; 4 | import _ from "lodash"; 5 | import { marked } from "marked"; 6 | import { messageBox, toast } from "user-view"; 7 | import { parseCSSRule } from "../elemental/styles"; 8 | import { userDialog } from "../render"; 9 | import { darkPrefers } from "../theme"; 10 | 11 | export type PageType = "index" | "thread" | "forum" | "user" | "unhandled" 12 | 13 | marked.setOptions({}); 14 | 15 | // function dt2PageType(s: string): PageType { 16 | // switch (s) { 17 | // case "index": 18 | // return "index"; 19 | // case "pb_bright": 20 | // return "thread"; 21 | // case "frs": 22 | // return "forum"; 23 | // case "main": 24 | // return "user"; 25 | // default: 26 | // return "unhandled"; 27 | // } 28 | // } 29 | 30 | /** 31 | * 获取当前页面的类型 32 | * @returns 当前页面的类型 33 | */ 34 | export function currentPageType(): PageType { 35 | // if (PageData) return dt2PageType(PageData.page); 36 | 37 | if (location.hostname.toLowerCase() !== "tieba.baidu.com") return "unhandled"; 38 | 39 | const pathname = location.pathname.toLocaleLowerCase(); 40 | 41 | if (_.includes(["/", "/index.html"], pathname)) return "index"; 42 | if (/\/p\/\d+/.test(pathname)) return "thread"; 43 | if (pathname === "/f") return "forum"; 44 | if (pathname === "/home/main") return "user"; 45 | 46 | return "unhandled"; 47 | } 48 | 49 | export async function getLatestReleaseFromGitee(forceUpdate = false): Promise> { 50 | if (latestRelease.get() && !forceUpdate) { 51 | return latestRelease.get(); 52 | } else { 53 | const TTL = (function () { 54 | switch (updateConfig.get().time) { 55 | case "1h": return 1; 56 | case "3h": return 3; 57 | case "6h": return 6; 58 | case "never": return -1; 59 | } 60 | })(); 61 | 62 | if (TTL < 0) return; 63 | 64 | const updateUrl = `https://gitee.com/api/v5/repos/${Owner}/${RepoName}/releases/latest/`; 65 | 66 | const response = await fetch(updateUrl); 67 | 68 | if (response.ok) { 69 | const result = await response.json(); 70 | if ((result as GiteeReleaseNotFound).message) return; 71 | 72 | latestRelease.set(result, spawnOffsetTS(0, 0, 0, TTL)); 73 | return result; 74 | } else { 75 | return; 76 | } 77 | } 78 | } 79 | 80 | export function checkUpdateAndNotify(showLatest = false) { 81 | // 不追踪发行信息 82 | if (updateConfig.get().time === "never") return; 83 | // 静默 84 | if (!updateConfig.get().notify) return; 85 | // 今日已不能再提醒 86 | if (!showUpdateToday.get()) return; 87 | 88 | // 开发者专用 89 | if (GM_info.script.version === "developer-only") return; 90 | 91 | getLatestReleaseFromGitee().then((latestRelease) => { 92 | if (latestRelease && latestRelease.tag_name.slice(1) !== GM_info.script.version) { 93 | // 忽略当前版本 94 | if (ignoredTag.get() === latestRelease.tag_name) return; 95 | 96 | userDialog( 97 | , 100 | { 101 | title: latestRelease.name, 102 | dialogButtons: [ 103 | { 104 | text: "安装", 105 | event() { 106 | installFromRelease(latestRelease); 107 | return true; 108 | }, 109 | style: "themed", 110 | }, 111 | { 112 | text: "今日不再提醒", 113 | event() { 114 | showUpdateToday.set(false); 115 | return true; 116 | }, 117 | }, 118 | { 119 | text: "跳过该版本", 120 | event() { 121 | ignoredTag.set(latestRelease.tag_name); 122 | return true; 123 | }, 124 | }, 125 | ], 126 | }); 127 | } else { 128 | if (showLatest) 129 | messageBox({ 130 | title: "检查更新", 131 | content: "当前已是最新版本", 132 | type: "okCancel", 133 | }); 134 | } 135 | }); 136 | } 137 | 138 | export function installFromRelease(release: GiteeRelease) { 139 | function notFound() { 140 | toast({ 141 | message: "安装失败:未找到可用的资源", 142 | type: "error", 143 | duration: 6000, 144 | blurEffect: true, 145 | }); 146 | } 147 | 148 | if (!release.assets || release.assets.length <= 0) { 149 | notFound(); 150 | return; 151 | } 152 | 153 | const asset = (function () { 154 | for (const asset of release.assets) { 155 | if (asset.name && asset.name.endsWith(".user.js")) { 156 | return asset.browser_download_url; 157 | } 158 | } 159 | })(); 160 | 161 | if (asset) { 162 | GM_openInTab(asset, { 163 | active: true, 164 | }); 165 | } else { 166 | notFound(); 167 | return; 168 | } 169 | } 170 | 171 | export function getResource(path: string) { 172 | return `${GiteeRepo}/raw/beta/${path}`; 173 | } 174 | 175 | export function setTheme(theme: ReturnType) { 176 | switch (theme) { 177 | case "dark": 178 | darkTheme(); 179 | break; 180 | 181 | case "light": 182 | lightTheme(); 183 | break; 184 | 185 | case "auto": 186 | default: 187 | darkPrefers.matches ? darkTheme() : lightTheme(); 188 | break; 189 | } 190 | 191 | function lightTheme() { 192 | document.documentElement.classList.add("light-theme"); 193 | document.documentElement.classList.remove("dark-theme"); 194 | document.documentElement.classList.remove("dark"); 195 | 196 | waitUntil(() => !_.isNil(document.body)).then(function () { 197 | document.body.classList.remove("dark-theme"); 198 | }); 199 | } 200 | 201 | function darkTheme() { 202 | document.documentElement.classList.add("dark-theme"); 203 | document.documentElement.classList.remove("light-theme"); 204 | document.documentElement.classList.add("dark"); 205 | 206 | waitUntil(() => !_.isNil(document.body)).then(function () { 207 | document.body.classList.add("dark-theme"); 208 | }); 209 | } 210 | } 211 | 212 | export function backupUserConfigs() { 213 | const excluded = ["unreadFeeds", "latestRelease", "showUpdateToday"]; 214 | const userKeys = _.filter(GM_listValues(), key => !_.includes(excluded, key)); 215 | const userValues = _.map(userKeys, key => { 216 | return GM_getValue(key); 217 | }); 218 | const configs = _.zipObject(userKeys, userValues); 219 | outputFile(`tieba-remix-backup@${new Date().getTime()}.json`, JSON.stringify(configs)); 220 | } 221 | 222 | export async function restoreUserConfigs() { 223 | const backupData = JSON.parse(await selectLocalFile()); 224 | _.forEach(Object.entries(backupData), ([key, value]) => { 225 | GM_setValue(key, value); 226 | }); 227 | } 228 | -------------------------------------------------------------------------------- /src/components/post-container.vue: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 8 | {{ props.post.forum.name + " 吧" }} 9 | 10 | 11 | 12 | 13 | {{ props.post.title }} 14 | {{ props.post.content }} 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 29 | 30 | {{ props.post.author.name }} 31 | {{ props.post.time }} 32 | 33 | 34 | {{ props.post.replies }} 35 | 36 | 37 | 38 | 39 | 131 | 132 | 292 | --------------------------------------------------------------------------------
{{ text }}
{{ sh.content }}
{{ props.post.title }}
{{ props.post.content }}