├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── build.yml ├── src ├── core │ ├── selfimage.ts │ ├── updateCheck.ts │ ├── communicate.ts │ ├── settings.ts │ ├── eventbus.ts │ ├── filtering.ts │ ├── memo.ts │ ├── frame.ts │ ├── modules.ts │ └── frameComponent.vue ├── @types │ ├── global.d.ts │ ├── memo.ts │ ├── user.ts │ ├── utils.ts │ ├── block.d.ts │ ├── internal.modules.ts │ ├── post.ts │ ├── components.ts │ ├── core.ts │ └── module.ts ├── utils │ ├── color.ts │ ├── getURL.ts │ ├── types.ts │ ├── writeClipboard.ts │ ├── ip.ts │ ├── toast.ts │ ├── storage.ts │ ├── observe.ts │ ├── scrollDetection.ts │ ├── comment.ts │ ├── http.ts │ └── user.ts ├── background │ ├── index.ts │ ├── handlers │ │ ├── commands.ts │ │ ├── lifecycle.ts │ │ ├── database.ts │ │ └── contextMenu.ts │ └── messages │ │ ├── broadcast.ts │ │ └── store.ts ├── components │ ├── icon.vue │ ├── scroll.vue │ ├── timestamp.vue │ ├── countdown.vue │ ├── group.vue │ ├── user.vue │ ├── loader.vue │ ├── previewButton.vue │ ├── toast.vue │ ├── comment.vue │ └── dccon.vue ├── contents │ └── index.ts ├── popup │ └── components │ │ ├── settingItem.vue │ │ ├── settingsModule.vue │ │ ├── settingControl.vue │ │ ├── refresherInput.vue │ │ ├── options.vue │ │ ├── bubble.vue │ │ ├── checkbox.vue │ │ ├── range.vue │ │ └── module.vue ├── temp │ └── grecaptcha.ts ├── modules │ ├── imagesearch.ts │ ├── write.ts │ ├── stealth.ts │ ├── fonts.ts │ ├── data.ts │ └── layout.ts └── styles │ ├── memo.scss │ ├── animations.scss │ ├── darkmode.scss │ ├── stealth.scss │ └── layout.scss ├── assets ├── block.webp ├── close.webp ├── dccon.webp ├── error.webp ├── icon.png ├── pin.webp ├── share.webp ├── write.webp ├── change.webp ├── delete.webp ├── newtab.webp ├── oyster.webp ├── refresh.webp ├── upvote.webp ├── downvote.webp └── empty_comment.webp ├── renovate.json ├── pnpm-workspace.yaml ├── .idea ├── .gitignore ├── vcs.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── DCRefresher-Reborn.iml ├── compiler.xml ├── git_toolbox_prj.xml ├── discord.xml └── misc.xml ├── .parcelrc ├── tsconfig.json ├── README.md ├── .gitignore └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: green1052 2 | -------------------------------------------------------------------------------- /src/core/selfimage.ts: -------------------------------------------------------------------------------- 1 | const SELFIMAGE_NAMESPACE = "__REFRESHER_SELFIMAGE"; 2 | -------------------------------------------------------------------------------- /assets/block.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/block.webp -------------------------------------------------------------------------------- /assets/close.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/close.webp -------------------------------------------------------------------------------- /assets/dccon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/dccon.webp -------------------------------------------------------------------------------- /assets/error.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/error.webp -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/pin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/pin.webp -------------------------------------------------------------------------------- /assets/share.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/share.webp -------------------------------------------------------------------------------- /assets/write.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/write.webp -------------------------------------------------------------------------------- /assets/change.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/change.webp -------------------------------------------------------------------------------- /assets/delete.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/delete.webp -------------------------------------------------------------------------------- /assets/newtab.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/newtab.webp -------------------------------------------------------------------------------- /assets/oyster.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/oyster.webp -------------------------------------------------------------------------------- /assets/refresh.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/refresh.webp -------------------------------------------------------------------------------- /assets/upvote.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/upvote.webp -------------------------------------------------------------------------------- /assets/downvote.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/downvote.webp -------------------------------------------------------------------------------- /assets/empty_comment.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/green1052/DCRefresher-Reborn/HEAD/assets/empty_comment.webp -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const content = { 2 | fetch: window.fetch 3 | }; 4 | 5 | type ValueOf = T[keyof T]; 6 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | export const random = () => `#${Math.random().toString(16).slice(-6)}`; 2 | 3 | export default { 4 | random 5 | }; -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>green1052/renovate-config" 4 | ], 5 | "ignoreDeps": [ 6 | "@parcel/resolver-glob" 7 | ] 8 | } -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import "@plasmohq/messaging/background"; 2 | import {startHub} from "@plasmohq/messaging/pub-sub"; 3 | import "./handlers/*.ts"; 4 | 5 | startHub(); -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@parcel/watcher' 3 | - '@swc/core' 4 | - esbuild 5 | - lmdb 6 | - msgpackr-extract 7 | - sharp 8 | - tesseract.js 9 | -------------------------------------------------------------------------------- /src/utils/getURL.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | export const getURL = (url: string): string => browser.runtime.getURL(url); 4 | 5 | export default getURL; 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # 디폴트 무시된 파일 2 | /shelf/ 3 | /workspace.xml 4 | # 에디터 기반 HTTP 클라이언트 요청 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = T | null; 2 | export type NullableProperties = { 3 | [K in keyof O]: Nullable; 4 | }; 5 | export type ObjectEnum = { [K in V]: K }; 6 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@plasmohq/parcel-config", 3 | "resolvers": [ 4 | "@parcel/resolver-glob", 5 | "..." 6 | ], 7 | "reporters": [ 8 | "...", 9 | "parcel-reporter-clean-dist" 10 | ] 11 | } -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/@types/memo.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | type RefresherMemo = typeof import("../core/memo").default; 5 | 6 | type RefresherMemoType = "UID" | "NICK" | "IP"; 7 | 8 | interface RefresherMemoValue { 9 | text: string; 10 | color: string; 11 | gallery?: string; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/@types/user.ts: -------------------------------------------------------------------------------- 1 | import type {Nullable} from "../utils/types"; 2 | 3 | export {}; 4 | 5 | declare global { 6 | class User { 7 | nick: string; 8 | id: Nullable; 9 | ip_data: string; 10 | icon: Nullable; 11 | type: number; 12 | __ip: Nullable; 13 | } 14 | } -------------------------------------------------------------------------------- /src/@types/utils.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | type RefresherHTTP = typeof import("../utils/http").default; 5 | 6 | interface ISPInfo { 7 | name?: string; 8 | country?: string; 9 | color: string; 10 | detail?: string; 11 | } 12 | 13 | type RefresherIP = typeof import("../utils/ip").default; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/icon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/DCRefresher-Reborn.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /src/background/handlers/commands.ts: -------------------------------------------------------------------------------- 1 | import {sendToBackground} from "@plasmohq/messaging"; 2 | import browser from "webextension-polyfill"; 3 | 4 | browser.commands.onCommand.addListener((command) => { 5 | sendToBackground({ 6 | name: "broadcast", 7 | body: { 8 | type: "executeShortcut", 9 | data: command 10 | } 11 | }); 12 | }); -------------------------------------------------------------------------------- /src/background/handlers/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | import storage from "../../utils/storage"; 4 | 5 | if (process.env.NODE_ENV === "production") 6 | browser.runtime.onInstalled.addListener(async (details) => { 7 | const key = details.reason === "install" ? "refresher.firstInstall" : "refresher.updated"; 8 | await storage.set(key, true); 9 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": [ 4 | "node_modules" 5 | ], 6 | "include": [ 7 | ".plasmo/index.d.ts", 8 | "./**/*.ts", 9 | "./**/*.tsx" 10 | ], 11 | "compilerOptions": { 12 | "paths": { 13 | "~*": [ 14 | "./src/*" 15 | ] 16 | }, 17 | "baseUrl": "." 18 | } 19 | } -------------------------------------------------------------------------------- /src/utils/writeClipboard.ts: -------------------------------------------------------------------------------- 1 | export const writeClipboard = async (text: string) => { 2 | try { 3 | await navigator.clipboard.writeText(text); 4 | } catch { 5 | const textArea = document.createElement("textarea"); 6 | textArea.value = text; 7 | document.body.appendChild(textArea); 8 | textArea.focus(); 9 | textArea.select(); 10 | document.execCommand("copy"); 11 | document.body.removeChild(textArea); 12 | } 13 | }; 14 | 15 | export default {writeClipboard}; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 기능 요청 3 | about: 리프레셔 새로운 기능을 제안하세요 4 | title: '[ENHANCEMENT] ' 5 | labels: enhancement 6 | assignees: green1052 7 | 8 | --- 9 | 10 | ## 🎯 기능 요청 이유 11 | 이 기능이 필요한 이유나 해결하고자 하는 문제를 설명해주세요. 12 | 13 | ## 💡 제안하는 기능 14 | 구체적으로 어떤 기능을 원하는지 자세히 설명해주세요. 15 | 16 | ## 🔧 예상되는 동작 방식 17 | 이 기능이 어떻게 작동해야 하는지 단계별로 설명해주세요: 18 | 1. 19 | 2. 20 | 3. 21 | 22 | ## 🎨 UI/UX 제안 (해당되는 경우) 23 | 사용자 인터페이스나 경험에 관한 아이디어가 있다면 설명해주세요. 24 | 25 | ## 📝 추가 정보 26 | 기능과 관련된 추가 정보나 참고 자료가 있다면 작성해주세요. 27 | -------------------------------------------------------------------------------- /.idea/git_toolbox_prj.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/@types/block.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | type RefresherBlock = typeof import("../core/block").default; 5 | 6 | type RefresherBlockType = "NICK" | "ID" | "IP" | "TITLE" | "TEXT" | "COMMENT" | "DCCON" | "TAB" | "IMAGE"; 7 | 8 | type RefresherBlockDetectMode = "SAME" | "CONTAIN" | "NOT_SAME" | "NOT_CONTAIN"; 9 | 10 | interface RefresherBlockValue { 11 | content: string; 12 | isRegex: boolean; 13 | isAdvanced: boolean; 14 | gallery?: string; 15 | extra?: string; 16 | mode?: RefresherBlockDetectMode; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | -------------------------------------------------------------------------------- /src/@types/internal.modules.ts: -------------------------------------------------------------------------------- 1 | import type {Nullable} from "../utils/types"; 2 | 3 | export {}; 4 | 5 | declare global { 6 | interface MiniPreview { 7 | element: HTMLDivElement; 8 | init: boolean; 9 | lastRequest: number; 10 | controller: AbortController; 11 | lastElement: Nullable; 12 | lastTimeout: number; 13 | shouldOutHandle: boolean; 14 | cursorOut: boolean; 15 | create: (ev: MouseEvent, use: boolean, hide: boolean, interaction: boolean) => void; 16 | move: (ev: MouseEvent, use: boolean, interaction: boolean) => void; 17 | close: (use: boolean) => void; 18 | } 19 | } -------------------------------------------------------------------------------- /src/utils/ip.ts: -------------------------------------------------------------------------------- 1 | import type {Nullable} from "~utils/types"; 2 | 3 | import storage from "./storage"; 4 | 5 | let ipData: Record = {}; 6 | 7 | (async () => { 8 | ipData = await storage.get>("refresher.database.ip"); 9 | })(); 10 | 11 | export const ISPData = (ip: string): ISPInfo => { 12 | if (!ipData) throw new Error("IP data not loaded"); 13 | 14 | return { 15 | name: ipData?.[ip], 16 | color: "#6495ed" 17 | }; 18 | }; 19 | 20 | export const format = (data: ISPInfo): Nullable => { 21 | const {name} = data; 22 | return name ?? null; 23 | }; 24 | 25 | export default { 26 | ISPData, 27 | format 28 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 버그 제보 3 | about: 리프레셔 버그를 제보하세요 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: green1052 7 | 8 | --- 9 | 10 | ## 버그 설명 11 | 발생한 버그에 대해 명확하고 간결하게 설명해주세요. 12 | 13 | ## 재현 방법 14 | 버그를 재현하는 단계를 작성해주세요: 15 | 1. 16 | 2. 17 | 3. 18 | 4. 19 | 20 | ## ✅ 예상 동작 21 | 어떤 결과를 예상했는지 설명해주세요. 22 | 23 | ## ❌ 실제 동작 24 | 실제로 어떤 일이 일어났는지 설명해주세요. 25 | 26 | ## 📸 스크린샷 27 | 가능하다면 스크린샷을 첨부해주세요. 28 | 29 | ## 🖥️ 환경 30 | - **OS**: [e.g. Windows 11, macOS 13.0, Ubuntu 22.04] 31 | - **브라우저**: [e.g. Chrome 119, Firefox 118, Safari 17] 32 | - **리프레셔 버전**: [e.g. 1.0.0] 33 | - **재현 가능한 주소**: [해당되는 경우 작성] 34 | 35 | ## 📝 추가 정보 36 | 버그와 관련된 추가 정보나 내용 작성해주세요. 37 | 38 | ## ✔️ 확인사항 39 | - [ ] 최신 버전을 사용하고 있습니다. 40 | - [ ] 동일한 문제가 보고되지 않았습니다. 41 | - [ ] 브라우저 콘솔에서 에러 메시지를 확인했습니다. 42 | -------------------------------------------------------------------------------- /src/contents/index.ts: -------------------------------------------------------------------------------- 1 | import "../styles/index.scss"; 2 | import "../core/memo"; 3 | import "../core/block"; 4 | import "../core/updateCheck"; 5 | 6 | import type {PlasmoCSConfig} from "plasmo"; 7 | 8 | import filter from "../core/filtering"; 9 | import modules from "../core/modules"; 10 | // @ts-ignore 11 | import * as modulesList from "../modules/*.ts"; 12 | 13 | // @ts-ignore 14 | Promise.all(Object.values(modulesList).map((module) => modules.load(module.default))).then(filter.run); 15 | 16 | export const config: PlasmoCSConfig = { 17 | matches: ["https://*.dcinside.com/*"], 18 | exclude_matches: [ 19 | "https://event.dcinside.com/*", 20 | "https://h5.dcinside.com/*", 21 | "https://m.dcinside.com/*", 22 | "https://mall.dcinside.com/*", 23 | "https://wiki.dcinside.com/*" 24 | ], 25 | run_at: "document_start" 26 | }; -------------------------------------------------------------------------------- /src/utils/toast.ts: -------------------------------------------------------------------------------- 1 | import {type ComponentPublicInstance, createApp} from "vue"; 2 | 3 | import Toast from "../components/toast.vue"; 4 | 5 | const div = document.createElement("div"); 6 | div.className = "refresher-toast"; 7 | 8 | let instance: ComponentPublicInstance | null = null; 9 | 10 | document.addEventListener("DOMContentLoaded", () => { 11 | document.body.appendChild(div); 12 | instance = createApp(Toast).mount(div); 13 | }); 14 | 15 | window.addEventListener("keydown", (ev) => { 16 | if (instance?.open && ev.key === "Escape") instance.hide(); 17 | }); 18 | 19 | export const show = ( 20 | content: string, 21 | type: "info" | "error" | "warning" | "cake" = "info", 22 | autoClose: number = 5000, 23 | onClick?: (ev: MouseEvent) => void 24 | ): void => { 25 | if (!instance) throw new Error("Toast instance is not initialized"); 26 | 27 | instance.show(content, type, autoClose, onClick); 28 | }; 29 | 30 | export default { 31 | show 32 | }; -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 교정 12 | 13 | 14 | 15 | 16 | LanguageDetectionInspection 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | -------------------------------------------------------------------------------- /src/@types/post.ts: -------------------------------------------------------------------------------- 1 | import type {User} from "../utils/user"; 2 | 3 | export {}; 4 | 5 | declare global { 6 | interface IPostInfo { 7 | id: string; 8 | header?: string; 9 | title?: string; 10 | date?: string; 11 | expire?: string; 12 | user?: User; 13 | views?: string; 14 | upvotes?: string; 15 | downvotes?: string; 16 | contents?: string; 17 | commentId?: string; 18 | commentNo?: string; 19 | commentCount?: number; 20 | isNotice?: boolean; 21 | isAdult?: boolean; 22 | requireCaptcha?: boolean; 23 | requireCommentCaptcha?: boolean; 24 | disabledDownvote?: boolean; 25 | v_cur_t?: string; 26 | randomParam?: { name: string; value: string }; 27 | dom?: Document; 28 | } 29 | 30 | interface GalleryPreData { 31 | gallery: string; 32 | id: string; 33 | title?: string; 34 | link?: string; 35 | notice?: boolean; 36 | recommend?: boolean; 37 | type: string; 38 | } 39 | } -------------------------------------------------------------------------------- /src/popup/components/settingItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /src/background/messages/broadcast.ts: -------------------------------------------------------------------------------- 1 | import type {PlasmoMessaging} from "@plasmohq/messaging"; 2 | import browser from "webextension-polyfill"; 3 | 4 | interface BroadcastRequest { 5 | type: string; 6 | data?: any; 7 | } 8 | 9 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 10 | const {type, data} = req.body; 11 | 12 | try { 13 | const tabs = await browser.tabs.query({}); 14 | 15 | const promises: Promise[] = []; 16 | 17 | for (const tab of tabs) { 18 | if (!tab.id) continue; 19 | 20 | promises.push( 21 | browser.tabs 22 | .sendMessage(tab.id, { 23 | type, 24 | data 25 | }) 26 | .catch(() => { 27 | }) 28 | ); 29 | } 30 | 31 | await Promise.all(promises); 32 | res.send({success: true, sentTo: promises.length}); 33 | } catch (e) { 34 | console.error("Broadcast error:", e); 35 | res.send({success: false, error: e}); 36 | } 37 | }; 38 | 39 | export default handler; -------------------------------------------------------------------------------- /src/temp/grecaptcha.ts: -------------------------------------------------------------------------------- 1 | // import type { PlasmoCSConfig } from "plasmo"; 2 | 3 | // @ts-ignore 4 | $.getScript("https://www.google.com/recaptcha/api.js?render=6Lc-Fr0UAAAAAOdqLYqPy53MxlRMIXpNXFvBliwI", () => { 5 | window.addEventListener("message", (event) => { 6 | if (event.data.type === "refresherGrecaptcha" && event.data.action) { 7 | // @ts-ignore 8 | grecaptcha.ready(async () => { 9 | // @ts-ignore 10 | const token = await grecaptcha.execute("6Lc-Fr0UAAAAAOdqLYqPy53MxlRMIXpNXFvBliwI", { 11 | action: event.data.action 12 | }); 13 | 14 | window.postMessage({type: "refresherGrecaptchaToken", token}, "*"); 15 | }); 16 | } 17 | }); 18 | }); 19 | 20 | // export const config: PlasmoCSConfig = { 21 | // matches: ["https://*.dcinside.com/*"], 22 | // exclude_matches: [ 23 | // "https://event.dcinside.com/*", 24 | // "https://h5.dcinside.com/*", 25 | // "https://m.dcinside.com/*", 26 | // "https://mall.dcinside.com/*", 27 | // "https://wiki.dcinside.com/*" 28 | // ], 29 | // world: "MAIN" 30 | // }; -------------------------------------------------------------------------------- /src/core/updateCheck.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | import storage from "../utils/storage"; 4 | import toast from "../utils/toast"; 5 | 6 | (async () => { 7 | const [installed, updated] = await Promise.all([ 8 | storage.get("refresher.firstInstall"), 9 | storage.get("refresher.updated") 10 | ]); 11 | 12 | if (installed || updated) 13 | setTimeout(() => { 14 | const currentVersion = browser.runtime.getManifest().version; 15 | 16 | let content: string; 17 | 18 | if (installed) { 19 | content = `DCRefresher Reborn ${currentVersion}이 설치되었습니다, 오류 및 개선사항은 여기에서 알려주세요.`; 20 | storage.remove("refresher.firstInstall"); 21 | } else { 22 | content = `DCRefresher Reborn이 ${currentVersion}(으)로 업데이트되었습니다, 변경 사항은 여기에서 볼 수 있습니다.`; 23 | storage.set("refresher.updated", false); 24 | } 25 | 26 | toast.show(content, "info", 5000, () => 27 | window.open(`https://github.com/green1052/DCRefresher-Reborn/releases/tag/${currentVersion}`, "_blank") 28 | ); 29 | }, 3000); 30 | })(); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 |

17 | 18 | # DCRefresher Reborn 19 | 20 | 디시인사이드 개선 확장 프로그램 21 | 22 | 버그나 문의사항은 [Issues](https://github.com/green1052/DCRefresher-Reborn/issues), [디스코드 서버](https://discord.gg/SSW6Zuyjz6) 또는 [리프레셔 미니 갤러리](https://gall.dcinside.com/mini/board/lists/?id=bjwg64)를 23 | 이용해주세요. 24 | 25 | 설치 방법 등 확장프로그램에 관한 내용은 [홈페이지](https://dcrefresher.green1052.com)에서 확인해주세요. 26 | -------------------------------------------------------------------------------- /src/modules/imagesearch.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | import communicate from "../core/communicate"; 3 | 4 | export default { 5 | name: "이미지 검색", 6 | description: "이미지를 검색합니다.", 7 | memory: { 8 | id: "", 9 | currentImage: null 10 | }, 11 | enable: true, 12 | default_enable: true, 13 | func() { 14 | window.addEventListener("contextmenu", (ev) => { 15 | const $element = $(ev.target as HTMLElement); 16 | 17 | if ($element.is("img")) this.memory.currentImage = $element.attr("src"); 18 | }); 19 | 20 | this.memory.id = communicate.addHook("searchSauceNao", () => { 21 | if (!this.memory.currentImage?.includes("viewimage.php")) return; 22 | 23 | const url = new URL(this.memory.currentImage); 24 | url.host = "image.dcinside.com"; 25 | url.pathname = "/dccon.php"; 26 | 27 | window.open(`https://saucenao.com/search.php?url=${encodeURIComponent(url.toString())}`); 28 | }); 29 | }, 30 | revoke() { 31 | communicate.clearHook("searchSauceNao", this.memory.id); 32 | } 33 | } as RefresherModule<{ 34 | memory: { 35 | id: string; 36 | currentImage: string | null; 37 | }; 38 | }>; -------------------------------------------------------------------------------- /src/core/communicate.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | interface StorageStructure { 4 | uuid: string; 5 | run: (...args: any[]) => void; 6 | } 7 | 8 | const handlerStorage: Record = {}; 9 | 10 | browser.runtime.onMessage.addListener((message: any) => { 11 | if (typeof message !== "object" || !message.type) return; 12 | 13 | const handlers = handlerStorage[message.type]; 14 | if (!handlers) return; 15 | 16 | for (const handler of handlers) { 17 | handler.run(message.data); 18 | } 19 | }); 20 | 21 | export const addHook = (type: string, callback: (...args: any[]) => void): string => { 22 | handlerStorage[type] ??= []; 23 | 24 | const uuid = crypto.randomUUID(); 25 | 26 | handlerStorage[type].push({ 27 | uuid, 28 | run: callback 29 | }); 30 | 31 | return uuid; 32 | }; 33 | 34 | export const clearHook = (type: string, id: string): boolean => { 35 | const hooks = handlerStorage[type]; 36 | 37 | if (!hooks) return false; 38 | 39 | const oldLength = hooks.length; 40 | 41 | handlerStorage[type] = hooks.filter((hook) => hook.uuid !== id); 42 | 43 | return oldLength !== handlerStorage[type].length; 44 | }; 45 | 46 | export default { 47 | addHook, 48 | clearHook 49 | }; 50 | -------------------------------------------------------------------------------- /src/background/handlers/database.ts: -------------------------------------------------------------------------------- 1 | import ky from "ky"; 2 | import browser from "webextension-polyfill"; 3 | 4 | import storage from "../../utils/storage"; 5 | 6 | const CONSTANTS = { 7 | DATABASE_UPDATE_INTERVAL: 604800000, 8 | API_BASE_URL: "https://dcrefresher.green1052.com/data" 9 | } as const; 10 | 11 | const updateDatabase = async (): Promise => { 12 | const [version, ip, ban] = await Promise.all([ 13 | ky.get(`${CONSTANTS.API_BASE_URL}/version`).text(), 14 | ky.get(`${CONSTANTS.API_BASE_URL}/ip.json`).json(), 15 | ky.get(`${CONSTANTS.API_BASE_URL}/ban.json`).json() 16 | ]); 17 | 18 | await Promise.all([ 19 | storage.set("refresher.database.ip", ip), 20 | storage.set("refresher.database.ban", ban), 21 | storage.set("refresher.database.version", version), 22 | storage.set("refresher.database.lastUpdate", Date.now()) 23 | ]); 24 | }; 25 | 26 | if (process.env.NODE_ENV === "production") { 27 | browser.runtime.onInstalled.addListener(updateDatabase); 28 | 29 | (async () => { 30 | const lastUpdate = await storage.get("refresher.database.lastUpdate"); 31 | 32 | if (!lastUpdate || Date.now() - lastUpdate > CONSTANTS.DATABASE_UPDATE_INTERVAL) { 33 | updateDatabase(); 34 | } 35 | })(); 36 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*.*.*" 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v6 13 | 14 | - name: Setup pnpm 15 | uses: pnpm/action-setup@v4 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v6 19 | with: 20 | node-version: latest 21 | cache: pnpm 22 | 23 | - name: Install Dependencies 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Build 27 | run: | 28 | pnpm build 29 | 30 | - name: Release 31 | uses: softprops/action-gh-release@v2 32 | with: 33 | files: | 34 | build/firefox-mv2-prod.zip 35 | build/chrome-mv3-prod.zip 36 | generate_release_notes: true 37 | 38 | - name: Compress Source Code 39 | run: | 40 | zip -r src.zip src 41 | 42 | - name: Browser Platform Publish 43 | uses: PlasmoHQ/bpp@v3 44 | with: 45 | keys: ${{ secrets.BPP_KEYS }} -------------------------------------------------------------------------------- /src/components/scroll.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | 23 | -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import {Storage} from "@plasmohq/storage"; 2 | 3 | export const storage = new Storage({area: "local"}); 4 | 5 | export const get = (key?: string | null): Promise => 6 | key ? storage.get(key) : (storage.rawGetAll() as Promise); 7 | 8 | export const set = (key: string, value: T): Promise => storage.set(key, value); 9 | 10 | export const setObject = (items: Record): Promise => storage.setMany(items); 11 | 12 | export const remove = (keys: string | string[]): Promise => 13 | Array.isArray(keys) ? storage.removeMany(keys) : storage.remove(keys); 14 | 15 | export const clear = (): Promise => storage.clear(); 16 | 17 | export const moduleStorage = { 18 | async get(module: string, key?: string): Promise { 19 | const storageKey = key ? `refresher.module:${module}-${key}` : `refresher.module:${module}`; 20 | const value = await storage.get(storageKey); 21 | return typeof value === "string" && value.startsWith("{") ? JSON.parse(value) : value; 22 | }, 23 | set(module: string, key: string, value: unknown): void { 24 | storage.set(`refresher.module:${module}-${key}`, value); 25 | }, 26 | setGlobal(module: string, dump: unknown): void { 27 | storage.set(`refresher.module:${module}`, dump); 28 | } 29 | }; 30 | 31 | export default { 32 | storage, 33 | get, 34 | set, 35 | setObject, 36 | remove, 37 | clear, 38 | module: moduleStorage 39 | }; -------------------------------------------------------------------------------- /src/background/handlers/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | const contextMenuItems: browser.Menus.CreateCreatePropertiesType[] = [ 4 | { 5 | id: "blockSelected", 6 | title: "오른쪽 클릭한 유저 차단", 7 | contexts: ["all"], 8 | documentUrlPatterns: ["*://gall.dcinside.com/*"] 9 | }, 10 | { 11 | id: "memoSelected", 12 | title: "오른쪽 클릭한 유저 메모", 13 | contexts: ["all"], 14 | documentUrlPatterns: ["*://gall.dcinside.com/*"] 15 | }, 16 | { 17 | id: "dcconSelected", 18 | title: "오른쪽 클릭한 디시콘 차단", 19 | contexts: ["all"], 20 | documentUrlPatterns: ["*://gall.dcinside.com/*"] 21 | }, 22 | { 23 | id: "dcconAllSelected", 24 | title: "오른쪽 클릭한 디시콘 전체 차단", 25 | contexts: ["all"], 26 | documentUrlPatterns: ["*://gall.dcinside.com/*"] 27 | }, 28 | { 29 | id: "searchSauceNao", 30 | title: "SauceNao 검색", 31 | contexts: ["image"], 32 | documentUrlPatterns: ["*://gall.dcinside.com/*"] 33 | } 34 | ]; 35 | 36 | const createContextMenus = async (): Promise => { 37 | await browser.contextMenus.removeAll(); 38 | for (const contextMenu of contextMenuItems) { 39 | browser.contextMenus.create(contextMenu); 40 | } 41 | }; 42 | 43 | browser.contextMenus.onClicked.addListener((info, tab) => { 44 | browser.tabs.sendMessage(tab!.id!, { 45 | type: info.menuItemId 46 | }); 47 | }); 48 | 49 | browser.runtime.onStartup.addListener(createContextMenus); 50 | browser.runtime.onInstalled.addListener(createContextMenus); -------------------------------------------------------------------------------- /src/core/settings.ts: -------------------------------------------------------------------------------- 1 | import {sendToBackground} from "@plasmohq/messaging"; 2 | 3 | import storage from "../utils/storage"; 4 | import eventBus from "./eventbus"; 5 | 6 | export type SettingsStore = Record>; 7 | 8 | const settings_store: SettingsStore = {}; 9 | 10 | export const set = async (module: string, key: string, value: string | number | boolean): Promise => { 11 | eventBus.emit("refresherUpdateSetting", module, key, value); 12 | 13 | settings_store[module][key].value = value; 14 | await storage.set(`${module}.${key}`, value); 15 | 16 | eventBus.emit("refresherSettingsSync", settings_store); 17 | }; 18 | 19 | export const setStore = (module: string, key: string, value: string | number | boolean): void => { 20 | eventBus.emit("refresherUpdateSetting", module, key, value); 21 | settings_store[module][key].value = value; 22 | }; 23 | 24 | export const dump = (): Record => settings_store; 25 | 26 | export const load = async (module: string, key: string, settings: RefresherSettings): Promise => { 27 | settings_store[module] ??= {}; 28 | 29 | const got = (await storage.get(`${module}.${key}`)) ?? settings.default; 30 | settings.value = got; 31 | 32 | settings_store[module][key] = settings; 33 | 34 | return got; 35 | }; 36 | 37 | eventBus.on("refresherSettingsSync", (store) => { 38 | sendToBackground({ 39 | name: "store", 40 | body: { 41 | action: "update", 42 | type: "settings", 43 | data: { 44 | store 45 | } 46 | } 47 | }); 48 | }); 49 | 50 | export default { 51 | set, 52 | setStore, 53 | dump, 54 | load 55 | }; -------------------------------------------------------------------------------- /src/utils/observe.ts: -------------------------------------------------------------------------------- 1 | export const find = (element: string, parent: HTMLElement): Promise> => 2 | new Promise>((resolve, reject) => { 3 | let observer: MutationObserver | null = null; 4 | 5 | const timeout = window.setTimeout(() => { 6 | observer?.disconnect(); 7 | reject(`Couldn't find the element(${element}).`); 8 | }, 3000); 9 | 10 | observer = listen(element, parent, function (this: MutationObserver, elements) { 11 | observer?.disconnect(); 12 | 13 | if (timeout) window.clearTimeout(timeout); 14 | 15 | resolve(elements); 16 | }); 17 | }); 18 | 19 | export const listen = ( 20 | element: string, 21 | parent: HTMLElement, 22 | callback: (element: NodeListOf) => void 23 | ): MutationObserver => { 24 | const parentFind = parent.querySelectorAll(element); 25 | 26 | if (parentFind.length > 0) callback(parentFind); 27 | 28 | const observer = new MutationObserver(function (this: MutationObserver, mutations) { 29 | let executed = false; 30 | 31 | for (const mutation of mutations) { 32 | if (mutation.addedNodes.length === 0) continue; 33 | executed = true; 34 | break; 35 | } 36 | 37 | if (!executed) return; 38 | 39 | const lists = document.querySelectorAll(element); 40 | 41 | if (lists.length === 0) return; 42 | 43 | callback.bind(this)(lists); 44 | }); 45 | 46 | observer.observe(parent ?? document.documentElement, { 47 | childList: true, 48 | subtree: true 49 | }); 50 | 51 | return observer; 52 | }; 53 | 54 | export default { 55 | find, 56 | listen 57 | }; 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .plasmo 2 | build 3 | .idea/**/workspace.xml 4 | .idea/**/tasks.xml 5 | .idea/**/usage.statistics.xml 6 | .idea/**/dictionaries 7 | .idea/**/shelf 8 | .idea/**/aws.xml 9 | .idea/**/contentModel.xml 10 | .idea/**/dataSources/ 11 | .idea/**/dataSources.ids 12 | .idea/**/dataSources.local.xml 13 | .idea/**/sqlDataSources.xml 14 | .idea/**/dynamic.xml 15 | .idea/**/uiDesigner.xml 16 | .idea/**/dbnavigator.xml 17 | .idea/**/gradle.xml 18 | .idea/**/libraries 19 | cmake-build-*/ 20 | .idea/**/mongoSettings.xml 21 | *.iws 22 | out/ 23 | .idea_modules/ 24 | atlassian-ide-plugin.xml 25 | .idea/replstate.xml 26 | .idea/sonarlint/ 27 | com_crashlytics_export_strings.xml 28 | crashlytics.properties 29 | crashlytics-build.properties 30 | fabric.properties 31 | .idea/httpRequests 32 | .idea/caches/build_file_checksums.ser 33 | logs 34 | *.log 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | lerna-debug.log* 39 | .pnpm-debug.log* 40 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 41 | pids 42 | *.pid 43 | *.seed 44 | *.pid.lock 45 | lib-cov 46 | coverage 47 | *.lcov 48 | .nyc_output 49 | .grunt 50 | bower_components 51 | .lock-wscript 52 | build/Release 53 | node_modules/ 54 | jspm_packages/ 55 | web_modules/ 56 | *.tsbuildinfo 57 | .npm 58 | .eslintcache 59 | .stylelintcache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | .node_repl_history 65 | *.tgz 66 | .yarn-integrity 67 | .env 68 | .env.development.local 69 | .env.test.local 70 | .env.production.local 71 | .env.local 72 | .cache 73 | .parcel-cache 74 | .next 75 | out 76 | .nuxt 77 | dist 78 | .cache/ 79 | .vuepress/dist 80 | .temp 81 | .docusaurus 82 | .serverless/ 83 | .fusebox/ 84 | .dynamodb/ 85 | .tern-port 86 | .vscode-test 87 | .yarn/cache 88 | .yarn/unplugged 89 | .yarn/build-state.yml 90 | .yarn/install-state.gz 91 | .pnp.* 92 | -------------------------------------------------------------------------------- /src/popup/components/settingsModule.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/timestamp.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/countdown.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/core/eventbus.ts: -------------------------------------------------------------------------------- 1 | const lists: Record = {}; 2 | 3 | export const eventBus: RefresherEventBus = { 4 | /** 5 | * lists에 등록된 이벤트 콜백을 호출합니다. 6 | * 7 | * @param event 호출 할 이벤트 이름. 8 | * @param params 호출 할 이벤트에 넘길 인자. 9 | */ 10 | emit: (event: string, ...params: any[]) => { 11 | if (!lists[event]) return; 12 | 13 | for (const callback of lists[event]) { 14 | callback.func(...params); 15 | 16 | if (callback.once) eventBus.remove(event, callback.uuid); 17 | } 18 | }, 19 | 20 | emitNextTick: (event: string, ...params: any[]) => { 21 | return requestAnimationFrame(() => eventBus.emit(event, ...params)); 22 | }, 23 | 24 | /** 25 | * lists 에 이벤트 콜백을 등록합니다. 26 | * 27 | * @param event 등록 될 이벤트 이름. 28 | * @param callback 나중에 호출 될 이벤트 콜백 함수. 29 | * @param options 이벤트에 등록할 옵션. 30 | */ 31 | on: (event: string, callback: () => void, options?: RefresherEventBusOptions): string => { 32 | const uuid = crypto.randomUUID(); 33 | 34 | lists[event] ??= []; 35 | 36 | const obj: RefresherEventBusObject = { 37 | func: callback, 38 | uuid 39 | }; 40 | 41 | if (options?.once) { 42 | obj.once = true; 43 | } 44 | 45 | lists[event].push(obj); 46 | 47 | return uuid; 48 | }, 49 | 50 | /** 51 | * lists 에 있는 이벤트 콜백을 제거합니다. 52 | */ 53 | remove: (event: string, uuid: string, skip?: boolean) => { 54 | if (skip && !lists[event]) return; 55 | 56 | if (!lists[event]) throw "Given Event is not exists in the list."; 57 | 58 | const index = lists[event].findIndex((callback) => callback.uuid == uuid); 59 | 60 | if (index == -1) throw "Given UUID is not exists in the list."; 61 | 62 | lists[event].splice(index, 1); 63 | } 64 | }; 65 | 66 | export default eventBus; 67 | -------------------------------------------------------------------------------- /src/styles/memo.scss: -------------------------------------------------------------------------------- 1 | .refresher-memo-frame { 2 | .head { 3 | font-size: 20px; 4 | font-weight: bold; 5 | margin-bottom: 5px; 6 | } 7 | 8 | .mute { 9 | font-size: 14px; 10 | font-weight: normal; 11 | opacity: 0.7; 12 | } 13 | 14 | .memo-row { 15 | margin-bottom: 25px; 16 | } 17 | 18 | .refresher-input-wrap { 19 | width: 100%; 20 | } 21 | 22 | .memo-user-type { 23 | display: flex; 24 | height: 45px; 25 | position: relative; 26 | width: 100%; 27 | 28 | .user-type { 29 | background-color: rgba(90, 90, 90, 0.08); 30 | border-radius: 13.3px; 31 | cursor: pointer; 32 | display: flex; 33 | flex: 1; 34 | 35 | margin-right: 10px; 36 | 37 | min-height: 45px; 38 | min-width: 100px; 39 | 40 | p { 41 | margin: auto; 42 | } 43 | 44 | &.active { 45 | background-color: rgba(90, 90, 90, 0.22); 46 | transition: 0.3s all cubic-bezier(0.19, 1, 0.22, 1); 47 | } 48 | 49 | &.disable { 50 | background-color: rgba(90, 90, 90, 0.04); 51 | cursor: not-allowed; 52 | opacity: 0.6; 53 | } 54 | } 55 | } 56 | 57 | .button-wrap { 58 | display: flex; 59 | margin-left: auto; 60 | width: fit-content; 61 | } 62 | 63 | .refresher-preview-button { 64 | p { 65 | margin: auto; 66 | } 67 | } 68 | } 69 | 70 | html:has(#css-darkmode) { 71 | .memo-user-type { 72 | .user-type { 73 | &.active { 74 | background-color: rgba(255, 255, 255, 0.22); 75 | } 76 | 77 | &.disable { 78 | background-color: rgba(255, 255, 255, 0.06); 79 | opacity: 0.6; 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/popup/components/settingControl.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | -------------------------------------------------------------------------------- /src/popup/components/refresherInput.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/group.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 80 | 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "DCRefresher Reborn", 3 | "name": "dcrefresher-reborn", 4 | "version": "4.1.2", 5 | "description": "디시인사이드 개선 확장 프로그램", 6 | "homepage": "https://dcrefresher.green1052.com", 7 | "license": "GPL-3.0-only", 8 | "author": "green1052", 9 | "scripts": { 10 | "dev": "plasmo dev", 11 | "build": "plasmo build --hoist --zip && plasmo build --hoist --zip --target=firefox-mv2" 12 | }, 13 | "dependencies": { 14 | "@plasmohq/messaging": "^0.7.2", 15 | "@plasmohq/storage": "^1.15.0", 16 | "plasmo": "^0.90.5", 17 | "vue": "^3.5.26" 18 | }, 19 | "devDependencies": { 20 | "@parcel/resolver-glob": "2.9.3", 21 | "@plasmohq/parcel-config": "^0.42.0", 22 | "@types/js-cookie": "^3.0.6", 23 | "@types/node": "^24.10.4", 24 | "@types/webextension-polyfill": "^0.12.4", 25 | "@typescript/native-preview": "7.0.0-dev.20251222.1", 26 | "cash-dom": "^8.1.5", 27 | "js-cookie": "^3.0.5", 28 | "ky": "^1.14.1", 29 | "parcel-reporter-clean-dist": "^1.0.4", 30 | "sass": "^1.97.1", 31 | "tesseract.js": "^7.0.0", 32 | "webextension-polyfill": "^0.12.0" 33 | }, 34 | "packageManager": "pnpm@10.26.1", 35 | "private": true, 36 | "manifest": { 37 | "host_permissions": [ 38 | "https://*.dcinside.com/*" 39 | ], 40 | "permissions": [ 41 | "activeTab", 42 | "contextMenus", 43 | "storage", 44 | "scripting", 45 | "unlimitedStorage", 46 | "clipboardWrite" 47 | ], 48 | "web_accessible_resources": [ 49 | { 50 | "resources": [ 51 | "assets/*.webp" 52 | ], 53 | "matches": [ 54 | "" 55 | ] 56 | } 57 | ], 58 | "commands": { 59 | "refreshLists": { 60 | "suggested_key": { 61 | "default": "Alt+R" 62 | }, 63 | "description": "글 목록 새로고침: 새로고침" 64 | }, 65 | "refreshPause": { 66 | "suggested_key": { 67 | "default": "Alt+S" 68 | }, 69 | "description": "글 목록 새로고침: 일시 비활성화" 70 | }, 71 | "stealthPause": { 72 | "suggested_key": { 73 | "default": "Alt+P" 74 | }, 75 | "description": "스텔스 모드: 일시 비활성화" 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /src/@types/components.ts: -------------------------------------------------------------------------------- 1 | import type {ComponentPublicInstance} from "vue"; 2 | 3 | import type {Nullable} from "../utils/types"; 4 | import type {User} from "../utils/user"; 5 | 6 | export {}; 7 | 8 | declare global { 9 | interface DcinsideDcconDetail { 10 | list: DcinsideDcconDetailList[]; 11 | max_page: number; 12 | target: string; 13 | } 14 | 15 | interface DcinsideDcconDetailList { 16 | detail: DcinsideDccon[]; 17 | detail_page: number; 18 | end_date: string; 19 | icon_cnt: number; 20 | main_img_url: string; 21 | package_idx: string; 22 | sort: string; 23 | title: string; 24 | } 25 | 26 | interface DcinsideDccon { 27 | detail_idx: string; 28 | list_img: string; 29 | package_idx: string; 30 | package_title: string; 31 | sort: string; 32 | title: string; 33 | } 34 | 35 | interface DcinsideCommentObject { 36 | a_my_cmt: "Y" | "N"; 37 | c_no: 0 | string; 38 | del_btn: "Y" | "N"; 39 | del_yn: "Y" | "N"; 40 | depth: 0 | 1; 41 | gallog_icon: string; 42 | ip: string; 43 | is_delete: string; 44 | memo: string; 45 | mod_btn: "Y" | "N"; 46 | my_cmt: "Y" | "N"; 47 | name: string; 48 | nicktype?: string; 49 | nickname: string; 50 | no: string; 51 | parent: string; 52 | password_pop: string; 53 | rcnt: string; 54 | reg_date: string; 55 | reply_w: "Y" | "N"; 56 | t_ch1: string; 57 | t_ch2: string; 58 | user_id: string; 59 | voice: string | null; 60 | vr_player: boolean | string; 61 | vr_player_tag: string; 62 | vr_type: string; 63 | user: User; 64 | } 65 | 66 | interface DcinsideComments { 67 | comments: Nullable; 68 | total_cnt: number; 69 | } 70 | 71 | interface RefresherFrameAppVue extends ComponentPublicInstance { 72 | changeStamp: () => void; 73 | first: () => RefresherFrame; 74 | second: () => RefresherFrame; 75 | clearScrollMode: () => void; 76 | outerClick: () => void; 77 | close: () => void; 78 | fadeIn: () => void; 79 | fadeOut: () => void; 80 | frames: RefresherFrame[]; 81 | closed: boolean; 82 | inputFocus: boolean; 83 | groupRef?: { 84 | frameRefs?: Array<{ 85 | incrementCommentKey?: () => void; 86 | commentKey?: { value: number }; 87 | }>; 88 | }; 89 | } 90 | } -------------------------------------------------------------------------------- /src/modules/write.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | 3 | export default { 4 | name: "글쓰기", 5 | description: "글쓰기 페이지를 변경합니다.", 6 | url: /\/board\/(write|modify)/, 7 | status: {}, 8 | memory: { 9 | submitButton: "" 10 | }, 11 | enable: false, 12 | default_enable: false, 13 | settings: { 14 | bypassTitleLimit: { 15 | name: "제목 글자수 제한 우회", 16 | desc: "제목 글자수 제한을 우회합니다.", 17 | type: "check", 18 | default: false 19 | }, 20 | header: { 21 | name: "머리말", 22 | desc: "머리말을 설정합니다. (HTML)", 23 | type: "text", 24 | default: "" 25 | }, 26 | footer: { 27 | name: "꼬리말", 28 | desc: "꼬리말을 설정합니다. (HTML)", 29 | type: "text", 30 | default: "" 31 | }, 32 | preventExit: { 33 | name: "나가기 방지", 34 | desc: "글 작성 중 나가기를 방지합니다.", 35 | type: "check", 36 | default: false 37 | } 38 | }, 39 | require: ["filter"], 40 | func(filter) { 41 | window.addEventListener("beforeunload", (ev) => { 42 | if (this.status.preventExit && !$("button:hover").eq(-1).hasClass("write")) { 43 | ev.preventDefault(); 44 | } 45 | }); 46 | 47 | this.memory.submitButton = filter.add("button.write", (element) => { 48 | $(element).on("click", () => { 49 | const $editor = $(".note-editable"); 50 | 51 | if (this.status.header) { 52 | $editor.prepend(this.status.header); 53 | } 54 | 55 | if (this.status.footer) { 56 | $editor.append(this.status.footer); 57 | } 58 | 59 | if (this.status.bypassTitleLimit) { 60 | const $titleElement = $("input#subject"); 61 | const title = $titleElement.val() as string; 62 | 63 | if (title.length === 1) $titleElement.val(`${title}\u200B`); 64 | } 65 | }); 66 | 67 | filter.remove(this.memory.submitButton); 68 | }); 69 | }, 70 | revoke(filter) { 71 | filter.remove(this.memory.submitButton); 72 | } 73 | } as RefresherModule<{ 74 | memory: { 75 | submitButton: string; 76 | }; 77 | settings: { 78 | bypassTitleLimit: RefresherCheckSettings; 79 | header: RefresherTextSettings; 80 | footer: RefresherTextSettings; 81 | preventExit: RefresherCheckSettings; 82 | }; 83 | require: ["filter"]; 84 | }>; -------------------------------------------------------------------------------- /src/components/user.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | -------------------------------------------------------------------------------- /src/@types/core.ts: -------------------------------------------------------------------------------- 1 | import type {User} from "../utils/user"; 2 | 3 | export {}; 4 | 5 | declare global { 6 | interface RefresherFilteringLists { 7 | func: (element: T) => void; 8 | scope: string; 9 | events: Record void>>; 10 | options?: RefresherFilteringOptions; 11 | expire?: () => void; 12 | } 13 | 14 | interface RefresherFilteringOptions { 15 | neverExpire?: boolean; 16 | skipIfNotExists?: boolean; 17 | } 18 | 19 | type RefresherFilter = typeof import("../core/filtering").filter; 20 | 21 | interface RefresherEventBusOptions { 22 | once: boolean; 23 | } 24 | 25 | interface RefresherEventBus { 26 | emit: (event: string, ...params: unknown[]) => void; 27 | emitNextTick: (event: string, ...params: unknown[]) => void; 28 | on: (event: string, callback: (...args: unknown[]) => void, options?: RefresherEventBusOptions) => string; 29 | remove: (event: string, uuid: string, skip?: boolean) => void; 30 | } 31 | 32 | interface RefresherEventBusObject { 33 | func: (...params: unknown[]) => void; 34 | uuid: string; 35 | once?: boolean; 36 | } 37 | 38 | interface RefresherFrame { 39 | title: string; 40 | subtitle: string; 41 | app: RefresherFrameAppVue; 42 | contents: string | undefined; 43 | upvotes: string | undefined; 44 | fixedUpvotes: string | undefined; 45 | downvotes: string | undefined; 46 | error?: { title: string; detail: string } | undefined; 47 | collapse?: boolean; 48 | data: { 49 | load: boolean; 50 | buttons: boolean; 51 | disabledDownvote: boolean; 52 | user: User | undefined; 53 | date: Date | undefined; 54 | expire: Date | undefined; 55 | views: string | undefined; 56 | useWriteComment: boolean; 57 | comments: DcinsideComments | undefined; 58 | type: string; 59 | useImageBlock: boolean; 60 | }; 61 | functions: { 62 | vote(type: number): Promise; 63 | share(): boolean; 64 | load(useCache?: boolean): Promise; 65 | retry(useCache?: boolean): void; 66 | openOriginal(): boolean; 67 | writeComment( 68 | type: "text" | "dccon", 69 | memo: string | DcinsideDccon[], 70 | commentNo: string | null, 71 | replyNo: string | null, 72 | user: { name: string; pw?: string }, 73 | bigDccon: boolean 74 | ): Promise; 75 | deleteComment(commentId: string, password: string, admin: boolean): Promise; 76 | }; 77 | } 78 | } -------------------------------------------------------------------------------- /src/core/filtering.ts: -------------------------------------------------------------------------------- 1 | import * as observe from "../utils/observe"; 2 | 3 | const lists: Record = {}; 4 | 5 | export const filter = { 6 | __run: async (filteringLists: RefresherFilteringLists, elements: NodeListOf): Promise => { 7 | for (const element of elements) { 8 | filteringLists.func(element); 9 | } 10 | }, 11 | 12 | run: async (): Promise => { 13 | for (const filterObj of Object.values(lists)) { 14 | if (filterObj.options?.neverExpire) { 15 | filterObj.expire?.(); 16 | 17 | const observer = observe.listen(filterObj.scope, document.documentElement, (e) => { 18 | filter.__run(filterObj, e); 19 | }); 20 | 21 | filterObj.expire = () => observer.disconnect(); 22 | 23 | continue; 24 | } 25 | 26 | observe 27 | .find(filterObj.scope, document.documentElement) 28 | .then((e) => filter.__run(filterObj, e)) 29 | .catch((e) => { 30 | if (!filterObj.options?.skipIfNotExists) throw e; 31 | }); 32 | } 33 | }, 34 | 35 | runSpecific: (id: string): Promise => { 36 | const item = lists[id]; 37 | 38 | return observe.find(item.scope, document.documentElement).then((e) => filter.__run(item, e)); 39 | }, 40 | 41 | add: ( 42 | scope: string, 43 | callback: (element: T) => void, 44 | options?: RefresherFilteringOptions 45 | ): string => { 46 | const uuid = crypto.randomUUID(); 47 | 48 | lists[uuid] = { 49 | func: callback, 50 | scope, 51 | events: {}, 52 | options 53 | }; 54 | 55 | return uuid; 56 | }, 57 | 58 | remove: (uuid: string, skip?: boolean): void => { 59 | if (skip) return; 60 | 61 | if (!uuid) throw "Given UUID is not valid."; 62 | 63 | const event = lists[uuid]; 64 | 65 | if (!event) throw "Given UUID is not exists in the list."; 66 | 67 | filter.emit(uuid, "remove"); 68 | 69 | if (event.options?.neverExpire && typeof event.expire === "function") { 70 | event.expire(); 71 | } 72 | 73 | delete lists[uuid]; 74 | }, 75 | 76 | on: (uuid: string, event: string, cb: (...args: any[]) => void): void => { 77 | if (!uuid || !event) throw "Given UUID or event is not valid."; 78 | 79 | if (!event) throw "Given UUID is not exists in the list."; 80 | 81 | lists[uuid].events[event] ??= []; 82 | lists[uuid].events[event].push(cb); 83 | }, 84 | 85 | emit: (uuid: string, event: string, ...args: any[]): void => { 86 | if (!uuid || !event) throw "Given UUID or event is not valid."; 87 | if (!event) throw "Given UUID is not exists in the list."; 88 | 89 | const eventObj = lists[uuid].events[event]; 90 | 91 | if (!eventObj) return; 92 | 93 | for (const event of eventObj) event(...args); 94 | } 95 | }; 96 | 97 | export default filter; 98 | -------------------------------------------------------------------------------- /src/popup/components/options.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 46 | 47 | -------------------------------------------------------------------------------- /src/modules/stealth.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | 3 | import getURL from "../utils/getURL"; 4 | import toast from "../utils/toast"; 5 | 6 | const CONTROL_BUTTON = ".stealth_control_button"; 7 | const TEMPORARY_STEALTH = "stlth"; 8 | 9 | const tempButtonCreate = (element: HTMLElement): void => { 10 | const $element = $(element); 11 | 12 | const buttonNum = $(CONTROL_BUTTON).length; 13 | const contentNum = $(".write_div img, .write_div video").length; 14 | 15 | if (buttonNum !== 0 && contentNum === 0) return; 16 | 17 | const buttonFrame = document.createElement("div"); 18 | buttonFrame.classList.add(CONTROL_BUTTON.replace(".", "")); 19 | buttonFrame.classList.add("blur"); 20 | buttonFrame.innerHTML = ` 21 |
22 | 23 |

이미지 보이기

24 |
25 | `; 26 | const button = buttonFrame.querySelector("#tempview")!; 27 | const buttonText = buttonFrame.querySelector("#temp_button_text")!; 28 | 29 | button.addEventListener("click", () => { 30 | if ($element.hasClass(TEMPORARY_STEALTH)) { 31 | $element.removeClass(TEMPORARY_STEALTH); 32 | buttonText.innerText = "이미지 보이기"; 33 | } else { 34 | $element.addClass(TEMPORARY_STEALTH); 35 | buttonText.innerText = "이미지 숨기기"; 36 | } 37 | }); 38 | 39 | $element.prepend(buttonFrame); 40 | }; 41 | 42 | export default { 43 | name: "스텔스 모드", 44 | description: "페이지내에서 표시되는 이미지를 비활성화합니다.", 45 | memory: { 46 | contentViewUUID: null 47 | }, 48 | enable: false, 49 | default_enable: false, 50 | shortcuts: { 51 | stealthPause() { 52 | const button = $(`${CONTROL_BUTTON} > #tempview`); 53 | 54 | if (!button.length) return; 55 | 56 | button.get(0)!.click(); 57 | 58 | const content = $(document.documentElement).hasClass(TEMPORARY_STEALTH) 59 | ? "이미지를 보이게 했습니다." 60 | : "이미지를 숨겼습니다."; 61 | 62 | toast.show(content, "info"); 63 | } 64 | }, 65 | require: ["eventBus"], 66 | func(eventBus) { 67 | $(document.documentElement).addClass("refresherStealth"); 68 | 69 | if (!$(CONTROL_BUTTON).length) { 70 | window.addEventListener("load", () => { 71 | tempButtonCreate(document.documentElement); 72 | }); 73 | } 74 | 75 | this.memory.contentViewUUID = eventBus.on("contentPreview", (elem: HTMLElement) => { 76 | if (!$(CONTROL_BUTTON).length) tempButtonCreate(elem); 77 | }); 78 | }, 79 | revoke(eventBus) { 80 | $(document.documentElement).removeClass("refresherStealth"); 81 | 82 | for (const button of $(CONTROL_BUTTON)) { 83 | $(button).remove(); 84 | } 85 | 86 | if (this.memory.contentViewUUID !== null) { 87 | eventBus.remove("contentPreview", this.memory.contentViewUUID); 88 | } 89 | } 90 | } as RefresherModule<{ 91 | memory: { 92 | contentViewUUID: string | null; 93 | }; 94 | shortcuts: { 95 | stealthPause(): void; 96 | }; 97 | require: ["eventBus"]; 98 | }>; -------------------------------------------------------------------------------- /src/styles/animations.scss: -------------------------------------------------------------------------------- 1 | .refresher-opacity-fast-enter-active { 2 | transition: all 150ms cubic-bezier(0.19, 1, 0.22, 1); 3 | } 4 | 5 | .refresher-opacity-fast-leave-active { 6 | display: none; 7 | } 8 | 9 | .refresher-opacity-fast-enter-from, 10 | .refresher-opacity-fast-leave-to { 11 | opacity: 0; 12 | position: absolute; 13 | } 14 | 15 | .refresher-opacity-fast-enter-to { 16 | opacity: 1; 17 | } 18 | 19 | .refresher-opacity-enter-active { 20 | transition: all 450ms cubic-bezier(0.19, 1, 0.22, 1); 21 | } 22 | 23 | .refresher-opacity-leave-active { 24 | display: none; 25 | } 26 | 27 | .refresher-opacity-enter-from, 28 | .refresher-opacity-leave-to { 29 | opacity: 0; 30 | position: absolute; 31 | } 32 | 33 | .refresher-opacity-enter-to { 34 | opacity: 1; 35 | } 36 | 37 | .refresher-pop-in-enter-active, 38 | .refresher-pop-in-leave-active { 39 | position: absolute; 40 | transition: all 133ms cubic-bezier(0.68, -0.16, 0.46, 1.57); 41 | } 42 | 43 | .refresher-pop-in-enter-from, 44 | .refresher-pop-in-leave-to { 45 | opacity: 0; 46 | position: absolute; 47 | transform: scale(0.9); 48 | } 49 | 50 | .refresher-pop-in-enter-to { 51 | opacity: 1; 52 | transform: scale(1); 53 | } 54 | 55 | .refresher-slide-up-enter-active { 56 | transition: all 450ms cubic-bezier(0.19, 1, 0.22, 1); 57 | } 58 | 59 | .refresher-slide-up-leave-active { 60 | display: none; 61 | } 62 | 63 | .refresher-slide-up-enter-from, 64 | .refresher-slide-up-leave-to { 65 | opacity: 0; 66 | position: absolute; 67 | transform: translateY(10px); 68 | } 69 | 70 | .refresher-slide-up-enter-to { 71 | opacity: 1; 72 | transform: translateY(0px); 73 | } 74 | 75 | .refresher-next-post-enter-active, 76 | .refresher-next-post-leave-active, 77 | .refresher-prev-post-enter-active, 78 | .refresher-prev-post-leave-active { 79 | transition: all 450ms cubic-bezier(0.19, 1, 0.22, 1); 80 | } 81 | 82 | .refresher-next-post-enter-from, 83 | .refresher-next-post-leave-to { 84 | opacity: 0; 85 | transform: translateY(40%); 86 | } 87 | 88 | .refresher-next-post-enter-to { 89 | opacity: 1; 90 | transform: translateY(0px); 91 | } 92 | 93 | .refresher-prev-post-enter-from, 94 | .refresher-prev-post-leave-to { 95 | opacity: 0; 96 | transform: translateY(-40%); 97 | } 98 | 99 | .refresher-prev-post-enter-to { 100 | opacity: 1; 101 | transform: translateY(0px); 102 | } 103 | 104 | .refresher-shake-enter-active { 105 | animation: shake 0.32s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; 106 | backface-visibility: hidden; 107 | perspective: 1000px; 108 | transform: translate3d(0, 0, 0); 109 | transition: all 450ms cubic-bezier(0.19, 1, 0.22, 1); 110 | } 111 | 112 | .refresher-shake-leave-active { 113 | display: none; 114 | } 115 | 116 | @keyframes shake { 117 | 10%, 118 | 90% { 119 | transform: translate3d(-1px, 0, 0); 120 | } 121 | 122 | 20%, 123 | 80% { 124 | transform: translate3d(2px, 0, 0); 125 | } 126 | 127 | 30%, 128 | 50%, 129 | 70% { 130 | transform: translate3d(-4px, 0, 0); 131 | } 132 | 133 | 40%, 134 | 60% { 135 | transform: translate3d(4px, 0, 0); 136 | } 137 | } -------------------------------------------------------------------------------- /src/components/loader.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /src/core/memo.ts: -------------------------------------------------------------------------------- 1 | import {sendToBackground} from "@plasmohq/messaging"; 2 | 3 | import storage from "../utils/storage"; 4 | import communicate from "./communicate"; 5 | import eventBus from "./eventbus"; 6 | 7 | const MEMO_NAMESPACE = "__REFRESHER_MEMO"; 8 | 9 | /** 10 | * 타입의 이름을 저장한 객체입니다. 11 | */ 12 | export const TYPE_NAMES = { 13 | UID: "유저 ID", 14 | NICK: "닉네임", 15 | IP: "IP" 16 | }; 17 | 18 | const MEMO_TYPES_KEYS: RefresherMemoType[] = ["UID", "NICK", "IP"]; 19 | 20 | export type MemoCache = Record>; 21 | 22 | function SendToBackground() { 23 | sendToBackground({ 24 | name: "store", 25 | body: { 26 | action: "update", 27 | type: "memos", 28 | data: { 29 | updateMemos: true, 30 | memos_store: MEMO_CACHE 31 | } 32 | } 33 | }); 34 | } 35 | 36 | let MEMO_CACHE: MemoCache = { 37 | UID: {}, 38 | NICK: {}, 39 | IP: {} 40 | }; 41 | 42 | (async () => { 43 | for (const key of MEMO_TYPES_KEYS) { 44 | const memo = await storage.get>(`${MEMO_NAMESPACE}:${key}`); 45 | MEMO_CACHE[key] = memo ?? {}; 46 | } 47 | 48 | SendToBackground(); 49 | })(); 50 | 51 | const InternalAddToList = (type: RefresherMemoType, user: string, text: string, color: string, gallery?: string) => { 52 | MEMO_CACHE[type][user] = { 53 | text, 54 | color, 55 | gallery 56 | }; 57 | 58 | storage.set(`${MEMO_NAMESPACE}:${type}`, MEMO_CACHE[type]); 59 | }; 60 | 61 | const checkValidType = (type: string) => MEMO_TYPES_KEYS.some((key) => key === type); 62 | 63 | /** 64 | * 메모 목록에 추가합니다. 65 | * 66 | * @param type 메모 종류 67 | * @param user 유저 68 | * @param text 메모 내용 69 | * @param color 메모 색상 70 | * @param gallery 특정 갤러리에만 해당하면 갤러리의 ID 값 71 | */ 72 | export const add = (type: RefresherMemoType, user: string, text: string, color: string, gallery?: string): void => { 73 | if (!checkValidType(type)) { 74 | throw `${type} is not a valid mode. requires one of [${MEMO_TYPES_KEYS.join(", ")}]`; 75 | } 76 | 77 | InternalAddToList(type, user, text, color, gallery); 78 | SendToBackground(); 79 | }; 80 | 81 | /** 82 | * 메모 내용을 구합니다. 83 | * 84 | * @param type 메모 종류 85 | * @param user 유저 86 | */ 87 | export const get = (type: RefresherMemoType, user: string): RefresherMemoValue => { 88 | if (!checkValidType(type)) { 89 | throw `${type} is not a valid mode. requires one of [${MEMO_TYPES_KEYS.join(", ")}]`; 90 | } 91 | 92 | return MEMO_CACHE[type][user]; 93 | }; 94 | 95 | /** 96 | * 메모를 삭제합니다. 97 | * 98 | * @param type 메모 종류 99 | * @param user 유저 100 | */ 101 | export const remove = (type: RefresherMemoType, user: string): void => { 102 | if (!checkValidType(type)) { 103 | throw `${type} is not a valid mode. requires one of [${MEMO_TYPES_KEYS.join(", ")}]`; 104 | } 105 | 106 | delete MEMO_CACHE[type][user]; 107 | storage.set(`${MEMO_NAMESPACE}:${type}`, MEMO_CACHE[type]); 108 | SendToBackground(); 109 | }; 110 | 111 | communicate.addHook("memoSelected", () => { 112 | eventBus.emit("refresherUpdateUserMemo"); 113 | }); 114 | 115 | communicate.addHook("updateMemos", ({memos}) => { 116 | MEMO_CACHE = memos; 117 | }); 118 | 119 | export default { 120 | TYPE_NAMES, 121 | add, 122 | get, 123 | remove 124 | }; -------------------------------------------------------------------------------- /src/utils/scrollDetection.ts: -------------------------------------------------------------------------------- 1 | const average = (arr: number[]) => arr.reduce((a, b) => a + b) / arr.length; 2 | 3 | enum ScrollMode { 4 | NOT_DEFINED, 5 | FIXED, 6 | VARIABLE 7 | } 8 | 9 | interface ScrollSession { 10 | time: number[]; 11 | delta: number[]; 12 | peak: number; 13 | direction: number; 14 | fired: number; 15 | } 16 | 17 | export class ScrollDetection { 18 | lastEvent: number; 19 | events: Record void>>; 20 | session: ScrollSession; 21 | mode: number; 22 | 23 | constructor() { 24 | this.lastEvent = 0; 25 | this.events = {}; 26 | this.mode = 0; 27 | 28 | this.session = { 29 | time: [], 30 | delta: [], 31 | peak: 0, 32 | direction: 0, 33 | fired: 0 34 | }; 35 | 36 | this.initSession(); 37 | } 38 | 39 | initSession(): void { 40 | this.session = { 41 | time: [], 42 | delta: [], 43 | peak: 0, 44 | direction: 0, 45 | fired: 0 46 | }; 47 | } 48 | 49 | emit(event: string, ...args: any[]): void { 50 | this.events[event]?.forEach((func) => { 51 | func(...args); 52 | }); 53 | } 54 | 55 | listen(event: string, cb: (...args: any[]) => void): void { 56 | this.events[event] ??= []; 57 | 58 | this.events[event].push(cb); 59 | } 60 | 61 | scroll(ev: WheelEvent): void { 62 | this.emit("scroll", ev); 63 | this.session.fired = Date.now(); 64 | } 65 | 66 | addMouseEvent(ev: WheelEvent): void { 67 | const lastEvent = this.lastEvent; 68 | this.lastEvent = Date.now(); 69 | 70 | const absoluteDelta = Math.abs(ev.deltaY); 71 | if (absoluteDelta < 2) { 72 | this.initSession(); 73 | return; 74 | } 75 | 76 | if (Date.now() - lastEvent > 100) { 77 | this.initSession(); 78 | 79 | // 이전 세션이랑 다른 스크롤임 80 | } 81 | 82 | if (this.session.delta.length !== 0) { 83 | const lastDelta = this.session.delta[this.session.delta.length - 1]; 84 | 85 | if (lastDelta === 100 && average(this.session.delta) === 100) { 86 | this.mode = ScrollMode.FIXED; 87 | // delta 절댓값이 100으로 고정된 경우 (deltaY를 지원하지 않거나 마우스 움직임) 88 | 89 | if (!this.session.fired) this.scroll(ev); 90 | else if (Date.now() - lastEvent > 100) this.initSession(); 91 | } else { 92 | this.mode = ScrollMode.VARIABLE; 93 | // delta 절댓값이 다양한 값으로 나오는 경우 (노트북 트랙패드, 관성 스크롤 지원) 94 | 95 | if (lastDelta > this.session.peak) { 96 | // 감속 구간 진입 혹은 갑자기 새로운 이벤트 발생 97 | if (this.session.fired) { 98 | if (Date.now() - this.session.fired > 60 && lastDelta / 4 > absoluteDelta) { 99 | // 갑자기 새로운 이벤트로 발생한게 확실함 100 | this.initSession(); 101 | } 102 | // 스크롤 이벤트는 이미 불러졌고 scroll tail만 남아 있는 경우 103 | } else { 104 | this.scroll(ev); 105 | } 106 | } else { 107 | this.session.peak = Math.abs(ev.deltaY); 108 | } 109 | } 110 | } 111 | 112 | this.session.delta.push(Math.abs(ev.deltaY)); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/popup/components/bubble.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 80 | 81 | -------------------------------------------------------------------------------- /src/modules/fonts.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | 3 | export default { 4 | name: "폰트 교체", 5 | description: "페이지에 전반적으로 표시되는 폰트를 교체합니다.", 6 | status: {}, 7 | memory: { 8 | uuid: null 9 | }, 10 | enable: true, 11 | default_enable: true, 12 | settings: { 13 | customFonts: { 14 | name: "font-family 이름", 15 | desc: "페이지 폰트를 입력된 폰트로 교체합니다. (빈칸으로 둘 시 확장 프로그램 기본 폰트로 설정)", 16 | type: "text", 17 | default: "Noto Sans CJK KR, NanumGothic" 18 | }, 19 | changeDCFont: { 20 | name: "디시인사이드 폰트 교체", 21 | desc: "미리보기 창 같은 DCRefresher Reborn의 폰트 뿐만 아니라 디시인사이드의 폰트까지 교체합니다.", 22 | type: "check", 23 | default: true 24 | }, 25 | bodyFontSize: { 26 | name: "본문 폰트 크기 지정", 27 | desc: "본문의 기본 폰트 크기를 조정합니다. (미리보기 창은 + 2pt)", 28 | type: "range", 29 | default: 13, 30 | min: 5, 31 | max: 30, 32 | step: 1, 33 | unit: "pt" 34 | } 35 | }, 36 | update: { 37 | customFonts(fontName) { 38 | fontName ??= this.settings.customFonts.default; 39 | 40 | let $fontElement = $("#refresherFontStyle"); 41 | 42 | if (!$fontElement.length) { 43 | $fontElement = $(" -------------------------------------------------------------------------------- /src/styles/darkmode.scss: -------------------------------------------------------------------------------- 1 | $dark-background: #222; 2 | $dark-tint-light: #292929; 3 | $dark-tint-light: rgba(41, 41, 41, 1); 4 | $dark-tint-light-opacity: rgba(41, 41, 41, 0.85); 5 | $dark-tint2: #3d3d3d; 6 | $dark-tint3: #555; 7 | $dark-text-color: #ccc; 8 | $dark-text-color-dark: #9e9e9e; 9 | $dark-text-color-bright: #fff; 10 | $dark-text-subcolor: #777; 11 | $dark-text-divcolor: #444; 12 | $dark-text-disablecolor: #666; 13 | $dark-dc-color: #4987f7; 14 | $dark-dc-color-deep: #113475; 15 | $dark-highlight: #176ef1d5; 16 | $dark-red: #e21919; 17 | 18 | html:has(#css-darkmode) { 19 | .refresher-comment svg { 20 | filter: invert(1); 21 | } 22 | 23 | .refresher-frame, 24 | .refresher-mini-preview { 25 | background-color: $dark-tint-light; 26 | border: 1px solid #505050; 27 | color: $dark-text-color-bright; 28 | 29 | &.blur { 30 | background-color: $dark-tint-light-opacity; 31 | } 32 | 33 | & .read-more { 34 | background-image: linear-gradient(transparent 0%, black 80%); 35 | bottom: 0; 36 | left: 0; 37 | margin: 0; 38 | padding: 30px 0; 39 | position: absolute; 40 | text-align: center; 41 | width: 100%; 42 | } 43 | } 44 | 45 | .refresher-tooltip { 46 | background-color: $dark-tint-light; 47 | border: 1px solid #505050; 48 | color: $dark-text-color-bright; 49 | 50 | &.blur { 51 | background-color: $dark-tint-light-opacity; 52 | } 53 | } 54 | 55 | .refresher-frame-outer { 56 | &.background { 57 | background-color: rgba(82, 82, 82, 0.6); 58 | } 59 | } 60 | 61 | .refresher-management-panel { 62 | background: $dark-tint-light-opacity; 63 | border: 1px solid #505050; 64 | 65 | &.blur { 66 | backdrop-filter: blur(5px) saturate(150%); 67 | background-color: $dark-tint-light-opacity; 68 | } 69 | 70 | .button { 71 | p { 72 | color: $dark-text-color; 73 | } 74 | 75 | &:hover { 76 | background: rgba(110, 110, 110, 0.5); 77 | } 78 | } 79 | 80 | img { 81 | filter: invert(1); 82 | } 83 | } 84 | 85 | .refresher-block-popup, 86 | .refresher-captcha-popup { 87 | background-color: $dark-tint-light; 88 | border: 1px solid #505050; 89 | box-shadow: 0 0 16px rgba(51, 51, 51, 0.3); 90 | color: white; 91 | 92 | .close { 93 | .cross { 94 | background-color: #fff; 95 | } 96 | 97 | &:hover { 98 | .cross { 99 | background-color: rgb(202, 202, 202); 100 | } 101 | } 102 | } 103 | } 104 | 105 | .refresher-frame.preview .refresher-preview-contents { 106 | embed, 107 | img, 108 | video { 109 | background-color: #333; 110 | } 111 | } 112 | 113 | .refresher-preview-button { 114 | box-shadow: none; 115 | 116 | &.primary { 117 | background-color: rgba(0, 110, 255, 0.32); 118 | 119 | &:hover { 120 | background-color: rgba(0, 110, 255, 0.28); 121 | } 122 | 123 | &:active { 124 | background-color: rgba(0, 110, 255, 0.22); 125 | } 126 | } 127 | 128 | &:hover { 129 | background-color: rgba(255, 255, 255, 0.08); 130 | box-shadow: none; 131 | color: white; 132 | 133 | p { 134 | filter: invert(0); 135 | } 136 | } 137 | 138 | &:active { 139 | background-color: rgba(255, 255, 255, 0.12); 140 | box-shadow: none; 141 | 142 | p { 143 | filter: invert(0); 144 | } 145 | } 146 | 147 | p { 148 | color: $dark-text-color; 149 | } 150 | 151 | img { 152 | filter: invert(1); 153 | } 154 | } 155 | } -------------------------------------------------------------------------------- /src/popup/components/range.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 52 | 53 | -------------------------------------------------------------------------------- /src/core/frame.ts: -------------------------------------------------------------------------------- 1 | import {createApp} from "vue"; 2 | 3 | import {User} from "../utils/user"; 4 | import frame from "./frameComponent.vue"; 5 | 6 | interface FrameOption { 7 | relative?: boolean; 8 | center?: boolean; 9 | preview?: boolean; 10 | blur?: boolean; 11 | } 12 | 13 | export interface FrameStackOption { 14 | background?: boolean; 15 | onScroll?: (ev: WheelEvent, app: RefresherFrameAppVue, group: HTMLElement) => void; 16 | blur?: boolean; 17 | } 18 | 19 | class InternalFrame implements RefresherFrame { 20 | title = ""; 21 | subtitle = ""; 22 | contents: string | undefined = undefined; 23 | upvotes: string | undefined = undefined; 24 | fixedUpvotes: string | undefined = undefined; 25 | downvotes: string | undefined = undefined; 26 | error?: { title: string; detail: string } | undefined = undefined; 27 | collapse?: boolean = undefined; 28 | data: { 29 | load: boolean; 30 | buttons: boolean; 31 | disabledDownvote: boolean; 32 | user: User | undefined; 33 | date: Date | undefined; 34 | expire: string | undefined; 35 | views: string | undefined; 36 | useWriteComment: boolean; 37 | comments: DcinsideComments | undefined; 38 | }; 39 | functions: { 40 | vote(type: number): Promise; 41 | share(): boolean; 42 | load(useCache?: boolean): void; 43 | retry(useCache?: boolean): void; 44 | openOriginal(): boolean; 45 | writeComment( 46 | type: "text" | "dccon", 47 | memo: string | DcinsideDccon, 48 | reply: string | null, 49 | user: { name: string; pw?: string } 50 | ): Promise; 51 | deleteComment(commentId: string, password: string, admin: boolean): Promise; 52 | }; 53 | 54 | private eventListeners: Map = new Map(); 55 | 56 | constructor( 57 | public options: FrameOption, 58 | public app: RefresherFrameAppVue 59 | ) { 60 | this.data = {}; 61 | this.functions = {}; 62 | } 63 | 64 | // Event emitter methods for Vue 3 compatibility 65 | $on(event: string, callback: Function) { 66 | if (!this.eventListeners.has(event)) { 67 | this.eventListeners.set(event, []); 68 | } 69 | this.eventListeners.get(event)!.push(callback); 70 | } 71 | 72 | $emit(event: string, ...args: any[]) { 73 | const callbacks = this.eventListeners.get(event); 74 | if (callbacks) { 75 | callbacks.forEach((callback) => callback(...args)); 76 | } 77 | } 78 | 79 | $off(event: string, callback?: Function) { 80 | if (!callback) { 81 | this.eventListeners.delete(event); 82 | return; 83 | } 84 | 85 | const callbacks = this.eventListeners.get(event); 86 | if (callbacks) { 87 | const index = callbacks.indexOf(callback); 88 | if (index > -1) { 89 | callbacks.splice(index, 1); 90 | } 91 | } 92 | } 93 | } 94 | 95 | export default class { 96 | outer: HTMLElement; 97 | frame: RefresherFrame[]; 98 | app: RefresherFrameAppVue; 99 | 100 | constructor(children: FrameOption[], option: FrameStackOption) { 101 | if (document.readyState === "loading") { 102 | throw "Frame is not available before DOMContentLoaded event. (DOM isn't accessible)"; 103 | } 104 | 105 | this.outer = document.createElement("refresher-frame-outer"); 106 | document.body.appendChild(this.outer); 107 | 108 | this.frame = []; 109 | const app = createApp(frame, {option}); 110 | this.app = app.mount(this.outer) as RefresherFrameAppVue; 111 | 112 | for (const child of children) { 113 | const internalFrame = new InternalFrame(child, this.app); 114 | this.app.frames.push(internalFrame); 115 | } 116 | 117 | // Add $on method to app for backward compatibility 118 | if (!this.app.$on) { 119 | this.app.$on = (event: string, callback: Function) => { 120 | if (event === "close") { 121 | this.app.frames.forEach((frame) => (frame as InternalFrame).$on("close", callback)); 122 | } 123 | }; 124 | } 125 | } 126 | 127 | // Method to trigger close event on all frames 128 | triggerCloseEvent() { 129 | this.app.frames.forEach((frame) => (frame as InternalFrame).$emit("close")); 130 | } 131 | } -------------------------------------------------------------------------------- /src/@types/module.ts: -------------------------------------------------------------------------------- 1 | import type Frame from "../core/frame"; 2 | 3 | export {}; 4 | 5 | type ItemToRefresherArrayArgs = 6 | T["require"] extends Array 7 | ? { 8 | [K in keyof T["require"]]: T["require"][K] extends keyof ItemToRefresherMap 9 | ? ItemToRefresherMap[T["require"][K]] 10 | : never; 11 | } 12 | : never; 13 | 14 | declare global { 15 | interface ItemToRefresherMap { 16 | filter: RefresherFilter; 17 | Frame: typeof Frame; 18 | eventBus: RefresherEventBus; 19 | http: RefresherHTTP; 20 | ip: RefresherIP; 21 | block: RefresherBlock; 22 | memo: RefresherMemo; 23 | } 24 | 25 | type RefresherSettings = 26 | | RefresherCheckSettings 27 | | RefresherTextSettings 28 | | RefresherRangeSettings 29 | | RefresherOptionSettings; 30 | 31 | interface RefresherBaseSettings { 32 | type: Type; 33 | name: string; 34 | desc: string; 35 | value: Value; 36 | default: Value; 37 | advanced?: boolean; 38 | } 39 | 40 | interface RefresherCheckSettings extends RefresherBaseSettings<"check", boolean> { 41 | } 42 | 43 | interface RefresherTextSettings extends RefresherBaseSettings<"text", string> { 44 | } 45 | 46 | interface RefresherRangeSettings extends RefresherBaseSettings<"range", number> { 47 | min: number; 48 | max: number; 49 | step: number; 50 | unit: string; 51 | } 52 | 53 | interface RefresherOptionSettings extends RefresherBaseSettings<"option", string> { 54 | items: unknown; 55 | } 56 | 57 | interface RefresherModuleGeneric { 58 | data?: Record; 59 | memory?: Record; 60 | settings?: Record; 61 | shortcuts?: Record void>; 62 | require?: Array; 63 | } 64 | 65 | interface RefresherModule { 66 | /** 67 | * 모듈의 이름. 다른 모듈과 구별 짓는 값으로 사용되니 다른 모듈과 이름이 겹칠 수 없습니다. 68 | * 설정의 모듈 페이지에 표시됩니다. 69 | */ 70 | name: string; 71 | 72 | /** 73 | * 모듈의 설정. 설정의 모듈 페이지에 표시됩니다. 74 | */ 75 | description: string; 76 | 77 | /** 78 | * 해당 모듈이 작동할 URL regex. 79 | */ 80 | url?: RegExp; 81 | 82 | /** 83 | * 해당 모듈이 가질 상탯값. 모듈 설정 저장용으로 사용됩니다. 84 | */ 85 | status: T["settings"] extends Record 86 | ? { [K in keyof T["settings"]]: T["settings"][K]["default"] } 87 | : never; 88 | 89 | /** 90 | * 모듈 데이터를 영속적으로 저장하고 싶을 때 사용하는 객체. 이 객체에 값을 저장하면 확장 프로그램이 로드될 때 마다 해당 값을 불러옵니다. 91 | */ 92 | data?: T["data"]; 93 | 94 | /** 95 | * 해당 모듈이 가질 메모리 값. 모듈에 일시적으로 데이터를 저장하고 싶을 때 사용됩니다. 96 | */ 97 | memory: T["memory"]; 98 | 99 | /** 100 | * 모듈을 사용 설정할지에 대한 여부 값. 사용자가 설정하는 값이므로 가급적 프로그램적으로 이 값을 변경하지 마세요. 101 | */ 102 | enable: boolean; 103 | 104 | /** 105 | * 해당 모듈이 처음 로드될 때 해당 모듈을 사용 설정할지에 대한 여부입니다. (기본 내장 모듈만 해당) 106 | */ 107 | default_enable: boolean; 108 | 109 | /** 110 | * 설정 페이지에 등록할 설정 옵션 111 | */ 112 | settings?: T["settings"]; 113 | 114 | /** 115 | * 단축키가 입력되면 실행할 함수를 정의합니다. 116 | */ 117 | shortcuts: T["shortcuts"] extends Record void> 118 | ? Record void> 119 | : never; 120 | 121 | /** 122 | * 설정이 업데이트 됐을 시 호출할 함수를 정의합니다. 123 | */ 124 | update: T["settings"] extends Record 125 | ? { 126 | [K in keyof T["settings"]]: ( 127 | this: RefresherModule, 128 | value: T["settings"][K]["value"], 129 | ...args: ItemToRefresherArrayArgs 130 | ) => void; 131 | } 132 | : never; 133 | 134 | /** 135 | * 모듈에서 사용할 내장 유틸 목록. 136 | */ 137 | require?: T["require"] extends Array ? T["require"] : never; 138 | 139 | /** 140 | * 해당 모듈이 작동할 때를 처리하기 위한 함수. require에서 적어 넣은 변수 순서대로의 인자를 인자로 넘겨줍니다. 141 | */ 142 | func?: (...args: ItemToRefresherArrayArgs) => void; 143 | 144 | /** 145 | * 해당 모듈이 회수될 때 (비활성화될 때) 를 처리하기 위한 함수. require에서 적어 넣은 변수 순서대로의 인자를 인자로 넘겨줍니다. 146 | */ 147 | revoke?: (...args: ItemToRefresherArrayArgs) => void; 148 | } 149 | } -------------------------------------------------------------------------------- /src/popup/components/module.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 64 | 65 | -------------------------------------------------------------------------------- /src/components/previewButton.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 57 | 58 | -------------------------------------------------------------------------------- /src/styles/stealth.scss: -------------------------------------------------------------------------------- 1 | $dark-dc-color-deep: #113475; 2 | $dark-tint2: #3d3d3d; 3 | $dark-tint-light-opacity: rgba(41, 41, 41, 0.85); 4 | $dark-text-color: #ccc; 5 | 6 | //.imgwrap { 7 | // display: initial !important; 8 | //} 9 | 10 | .refresherStealth { 11 | .bgcover { 12 | visibility: hidden; 13 | } 14 | 15 | .rcontimg_box .img_box { 16 | display: none !important; 17 | } 18 | 19 | &:not(.stlth) .refresher-preview-contents-actual, 20 | &:not(.stlth) .writing_view_box { 21 | embed, 22 | img, 23 | video, 24 | #zzbang_div { 25 | display: none; 26 | } 27 | } 28 | 29 | div.comment_box .comment_dccon { 30 | &:not(:hover) { 31 | border: 1px solid $dark-dc-color-deep; 32 | 33 | box-sizing: border-box; 34 | 35 | .written_dccon { 36 | visibility: hidden; 37 | } 38 | 39 | &::before { 40 | align-items: center; 41 | content: "디시콘 표시"; 42 | display: flex; 43 | font-weight: bold; 44 | height: 100px; 45 | justify-content: space-around; 46 | position: absolute; 47 | width: 100px; 48 | } 49 | } 50 | } 51 | 52 | &:has(#css-darkmode) .coment_dccon_img { 53 | &:not(:hover) { 54 | border: 2px solid $dark-tint2; 55 | } 56 | } 57 | 58 | .dory { 59 | .dory_img img { 60 | height: 10pt; 61 | } 62 | 63 | .dory_rolling { 64 | margin-top: -17px; 65 | position: inherit; 66 | } 67 | 68 | .comment_dory { 69 | display: none; 70 | } 71 | 72 | .dory_txt { 73 | margin-left: 90px; 74 | margin-top: 0; 75 | } 76 | } 77 | 78 | .refresher-nocomment-wrap { 79 | img { 80 | display: none; 81 | } 82 | 83 | h3 { 84 | text-align: center; 85 | width: 100%; 86 | } 87 | } 88 | 89 | //Preview - dccon 90 | div.refresher-preview-comments .dccon:not(:hover) { 91 | .written_dccon { 92 | visibility: hidden; 93 | } 94 | 95 | &::before { 96 | align-items: center; 97 | border: 1px solid $dark-dc-color-deep; 98 | border-radius: 10px; 99 | box-sizing: border-box; 100 | content: "디시콘 표시"; 101 | display: flex; 102 | font-weight: bold; 103 | height: 100px; 104 | justify-content: space-around; 105 | position: absolute; 106 | width: 100px; 107 | } 108 | } 109 | 110 | &:has(#css-darkmode), 111 | div.refresher-preview-comments, 112 | .coment_dccon_img:not(:hover) { 113 | &::before { 114 | border: 1px solid $dark-tint2; 115 | } 116 | } 117 | 118 | .stealth_control_button { 119 | background: rgba(255, 255, 255, 0.8); 120 | border-radius: 0 13.3px 13.3px 0; 121 | bottom: 20px; 122 | display: grid; 123 | font-size: 13px; 124 | height: 80px; 125 | left: -1px; 126 | position: fixed; 127 | transform: translateX(-50px); 128 | transition: 0.3s all cubic-bezier(0.19, 1, 0.22, 1); 129 | user-select: none; 130 | width: 80px; 131 | z-index: 2002; 132 | 133 | &.blur { 134 | background-color: rgba(245, 245, 245, 0.85); 135 | } 136 | 137 | .button { 138 | border-radius: 13.3px; 139 | display: grid; 140 | height: 60px; 141 | margin: auto; 142 | padding: 5px; 143 | transition: 0.3s all cubic-bezier(0.19, 1, 0.22, 1); 144 | width: 60px; 145 | 146 | img { 147 | height: 40px; 148 | margin: auto; 149 | width: 40px; 150 | } 151 | 152 | p { 153 | color: #000; 154 | font-size: 10px; 155 | text-align: center; 156 | } 157 | 158 | &:hover { 159 | background: rgba(255, 255, 255, 1); 160 | } 161 | } 162 | 163 | &:hover { 164 | transform: translateX(0px); 165 | } 166 | } 167 | 168 | &:has(#css-darkmode) .stealth_control_button { 169 | background: $dark-tint-light-opacity; 170 | border: 1px solid #505050; 171 | 172 | &.blur { 173 | backdrop-filter: blur(5px) saturate(150%); 174 | background-color: $dark-tint-light-opacity; 175 | } 176 | 177 | .button { 178 | p { 179 | color: $dark-text-color; 180 | } 181 | 182 | &:hover { 183 | background: rgba(110, 110, 110, 0.5); 184 | } 185 | } 186 | 187 | img { 188 | filter: invert(1); 189 | } 190 | } 191 | } -------------------------------------------------------------------------------- /src/utils/comment.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | import ky from "ky"; 3 | 4 | import * as http from "./http"; 5 | import type {Nullable} from "./types"; 6 | 7 | const rKey = "yL/M=zNa0bcPQdReSfTgUhViWjXkYIZmnpo+qArOBs1Ct2D3uE4Fv5G6wHl78xJ9K"; 8 | const rRegex = /[^A-Za-z0-9+/=]/g; 9 | 10 | const decode = (r: string) => { 11 | let a; 12 | let e; 13 | let n; 14 | let t; 15 | let f; 16 | let d; 17 | let h; 18 | let o = ""; 19 | let c = 0; 20 | 21 | for (r = r.replace(rRegex, ""); c < r.length;) { 22 | t = rKey.indexOf(r.charAt(c++)); 23 | f = rKey.indexOf(r.charAt(c++)); 24 | d = rKey.indexOf(r.charAt(c++)); 25 | h = rKey.indexOf(r.charAt(c++)); 26 | a = (t << 2) | (f >> 4); 27 | e = ((15 & f) << 4) | (d >> 2); 28 | n = ((3 & d) << 6) | h; 29 | o += String.fromCharCode(a); 30 | 31 | if (d !== 64) o += String.fromCharCode(e); 32 | 33 | if (h !== 64) o += String.fromCharCode(n); 34 | } 35 | 36 | return o; 37 | }; 38 | 39 | const requestBeforeServiceCode = (dom: Document) => { 40 | const $dom = $(dom); 41 | 42 | const script = $dom.find("#reply-setting-tmpl + script"); 43 | 44 | if (!script.length) throw "_r 값을 찾을 수 없습니다."; 45 | 46 | const dValue = script.html().match(/_d\('(.*)'\)/)?.[1]; 47 | 48 | if (!dValue) throw "_d 값을 찾을 수 없습니다."; 49 | 50 | let _r = decode(dValue); 51 | 52 | if (!_r) throw "_r이 적절한 값이 아닙니다."; 53 | 54 | let fi = parseInt(_r.slice(0, 1)); 55 | fi = fi > 5 ? fi - 5 : fi + 4; 56 | 57 | _r = _r.replace(/^./, fi.toString()); 58 | 59 | const r = $dom.find("input[name=service_code]").val() as string; 60 | 61 | const _rs = _r.split(","); 62 | 63 | let t = ""; 64 | 65 | for (let e = 0; e < _rs.length; e++) { 66 | t += String.fromCharCode((2 * (Number(_rs[e]) - e - 1)) / (13 - e - 1)); 67 | } 68 | 69 | return r.replace(/(.{10})$/, t); 70 | }; 71 | 72 | const secretKey = (dom: Document): URLSearchParams => { 73 | const params = new URLSearchParams(); 74 | params.set("t_vch2", ""); 75 | params.set("t_vch2_chk", ""); 76 | 77 | for (const element of $(dom).find("#focus_cmt > input")) { 78 | const $element = $(element); 79 | 80 | const id = $element.attr("name") ?? $element.attr("id")!; 81 | 82 | if (!["service_code", "gallery_no", "clickbutton"].includes(id)) params.set(id, $element.val() as string); 83 | } 84 | 85 | return params; 86 | }; 87 | 88 | interface CommentResult { 89 | result: string; 90 | message: Nullable; 91 | } 92 | 93 | export async function submitComment( 94 | preData: GalleryPreData, 95 | user: { name: string; pw?: string }, 96 | dom: Document, 97 | memo: string | DcinsideDccon[], 98 | commentNo: string | null, 99 | replyNo: string | null, 100 | bigDccon: boolean, 101 | captcha?: string, 102 | grecaptcha?: string 103 | ): Promise { 104 | let code: string; 105 | 106 | try { 107 | code = requestBeforeServiceCode(dom); 108 | } catch (e) { 109 | return { 110 | result: "PreNotWorking", 111 | message: (e as string) || "사전에 정의되지 않은 오류." 112 | }; 113 | } 114 | 115 | const params = new URLSearchParams(secretKey(dom)); 116 | params.set("service_code", code); 117 | params.set("c_gall_id", preData.gallery); 118 | params.set("c_gall_no", preData.id); 119 | 120 | params.set("id", preData.gallery); 121 | params.set("no", preData.id); 122 | 123 | if (commentNo) params.set("c_no", commentNo); 124 | 125 | if (replyNo) params.set("reply_no", replyNo); 126 | 127 | params.set("name", user.name); 128 | if (user.pw) params.set("password", user.pw); 129 | params.set("use_gall_nick", "N"); 130 | 131 | if (captcha) params.set("code", captcha); 132 | 133 | if (grecaptcha) params.set("g-recaptcha-response", grecaptcha); 134 | 135 | if (bigDccon) params.set("bigdccon", "1"); 136 | 137 | if (typeof memo === "string") { 138 | params.set("memo", memo); 139 | } else { 140 | params.set("input_type", "comment"); 141 | 142 | if (memo.length > 1) params.set("double_con_chk", "1"); 143 | params.set("package_idx", memo.map((dccon) => dccon.package_idx).join(",")); 144 | params.set("detail_idx", memo.map((dccon) => dccon.detail_idx).join(",")); 145 | } 146 | 147 | const url = typeof memo === "string" ? http.urls.comments_submit : http.urls.dccon_comments_submit; 148 | const options = { 149 | method: "POST", 150 | headers: { 151 | "X-Requested-With": "XMLHttpRequest" 152 | }, 153 | body: params 154 | }; 155 | 156 | const response = 157 | process.env.PLASMO_MANIFEST_VERSION === "mv2" 158 | ? await content.fetch(url, options).then((response) => response.text()) 159 | : await ky(url, options).text(); 160 | 161 | const [result, message] = response.split("||"); 162 | 163 | return { 164 | result, 165 | message 166 | }; 167 | } -------------------------------------------------------------------------------- /src/modules/data.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | import toast from "../utils/toast"; 4 | import {writeClipboard} from "../utils/writeClipboard"; 5 | 6 | export default { 7 | name: "데이터 관리", 8 | description: "데이터를 관리합니다.", 9 | status: {}, 10 | data: { 11 | lastUpdate: -1 12 | }, 13 | enable: true, 14 | default_enable: true, 15 | settings: { 16 | autoBackup: { 17 | name: "자동 백업", 18 | desc: "하루마다 자동으로 데이터를 클라우드에 백업합니다.", 19 | type: "check", 20 | default: false, 21 | advanced: true 22 | }, 23 | backupCloud: { 24 | name: "클라우드 백업", 25 | desc: "클라우드에 데이터를 백업합니다.", 26 | type: "check", 27 | default: false, 28 | advanced: true 29 | }, 30 | recoverCloud: { 31 | name: "클라우드 복원", 32 | desc: "클라우드에서 데이터를 복원합니다.", 33 | type: "check", 34 | default: false, 35 | advanced: true 36 | }, 37 | exportData: { 38 | name: "데이터 내보내기", 39 | desc: "데이터를 내보냅니다.", 40 | type: "check", 41 | default: false, 42 | advanced: true 43 | }, 44 | importData: { 45 | name: "데이터 가져오기", 46 | desc: "데이터를 가져옵니다.", 47 | type: "check", 48 | default: false, 49 | advanced: true 50 | }, 51 | clearData: { 52 | name: "⚠️데이터 초기화⚠️", 53 | desc: "데이터를 초기화합니다.", 54 | type: "check", 55 | default: false, 56 | advanced: true 57 | } 58 | }, 59 | update: { 60 | backupCloud(this, _) { 61 | browser.storage.local.get().then(async (data) => { 62 | try { 63 | delete data["refresher.database.ip"]; 64 | delete data["refresher.database.ban"]; 65 | delete data["refresher.database.version"]; 66 | delete data["refresher.database.lastUpdate"]; 67 | 68 | await browser.storage.sync.clear(); 69 | await browser.storage.sync.set(data); 70 | 71 | toast.show("데이터를 클라우드에 백업했습니다."); 72 | } catch { 73 | toast.show("데이터를 클라우드에 백업하는데 실패했습니다.", "error"); 74 | } 75 | }); 76 | }, 77 | recoverCloud(this, _) { 78 | if (!confirm("ㄹ?ㅇ")) return; 79 | 80 | browser.storage.sync.get().then(async (data) => { 81 | try { 82 | // await storage.clear(); 83 | // await storage.setObject(data); 84 | 85 | await browser.storage.local.clear(); 86 | await browser.storage.local.set(data); 87 | 88 | toast.show("데이터를 복원했습니다."); 89 | } catch { 90 | toast.show("데이터를 복원하는데 실패했습니다.", "error"); 91 | } 92 | }); 93 | }, 94 | exportData(this, _) { 95 | browser.storage.local.get().then((data) => { 96 | delete data["refresher.database.ip"]; 97 | delete data["refresher.database.ban"]; 98 | delete data["refresher.database.version"]; 99 | delete data["refresher.database.lastUpdate"]; 100 | 101 | writeClipboard(JSON.stringify(data)) 102 | .then(() => toast.show("데이터를 클립보드로 내보냈습니다.")) 103 | .catch(() => toast.show("데이터를 클립보드로 내보내는데 실패했습니다.", "error")); 104 | }); 105 | }, 106 | importData(this, _) { 107 | (async () => { 108 | const input = prompt("데이터를 입력해주세요."); 109 | 110 | if (!input) return; 111 | 112 | try { 113 | const data: Record = JSON.parse(input); 114 | 115 | await browser.storage.local.clear(); 116 | await browser.storage.local.set(data); 117 | 118 | toast.show("데이터를 가져왔습니다."); 119 | } catch { 120 | toast.show("데이터를 가져오는데 실패했습니다.", "error"); 121 | } 122 | })(); 123 | }, 124 | clearData(this, _) { 125 | if (!confirm("ㄹ?ㅇ")) return; 126 | 127 | browser.storage.local 128 | .clear() 129 | .then(() => toast.show("데이터를 초기화했습니다.")) 130 | .catch(() => toast.show("데이터를 초기화하는데 실패했습니다.", "error")); 131 | } 132 | }, 133 | func() { 134 | if (!this.status.autoBackup) return; 135 | 136 | if (this.data.lastUpdate === -1) { 137 | this.update.backupCloud(); 138 | this.data.lastUpdate = Date.now(); 139 | return; 140 | } 141 | 142 | if (Date.now() - this.data.lastUpdate > 24 * 60 * 60 * 1000) { 143 | this.update.backupCloud(); 144 | this.data.lastUpdate = Date.now(); 145 | } 146 | } 147 | } as RefresherModule<{ 148 | data: { 149 | lastUpdate: number; 150 | }; 151 | settings: { 152 | autoBackup: RefresherCheckSettings; 153 | backupCloud: RefresherCheckSettings; 154 | recoverCloud: RefresherCheckSettings; 155 | exportData: RefresherCheckSettings; 156 | importData: RefresherCheckSettings; 157 | clearData: RefresherCheckSettings; 158 | }; 159 | }>; -------------------------------------------------------------------------------- /src/utils/http.ts: -------------------------------------------------------------------------------- 1 | export const urls = { 2 | base: "https://gall.dcinside.com/", 3 | gall: { 4 | major: "https://gall.dcinside.com/", 5 | mini: "https://gall.dcinside.com/mini/", 6 | minor: "https://gall.dcinside.com/mgallery/", 7 | person: "https://gall.dcinside.com/person/" 8 | }, 9 | view: "board/view/?id=", 10 | vote: "https://gall.dcinside.com/board/recommend/vote", 11 | captcha: "https://gall.dcinside.com/kcaptcha/session", 12 | manage: { 13 | bump: "https://gall.dcinside.com/ajax/minor_manager_board_ajax/update_bump", 14 | bumpMini: "https://gall.dcinside.com/ajax/mini_manager_board_ajax/update_bump", 15 | delete: "https://gall.dcinside.com/ajax/minor_manager_board_ajax/delete_list", 16 | deleteMini: "https://gall.dcinside.com/ajax/mini_manager_board_ajax/delete_list", 17 | deleteUser: "https://gall.dcinside.com/board/forms/delete_submit", 18 | deleteComment: "https://gall.dcinside.com/ajax/minor_manager_board_ajax/delete_comment", 19 | deleteCommentMini: "https://gall.dcinside.com/ajax/mini_manager_board_ajax/delete_comment", 20 | setNotice: "https://gall.dcinside.com/ajax/minor_manager_board_ajax/set_notice", 21 | setNoticeMini: "https://gall.dcinside.com/ajax/mini_manager_board_ajax/set_notice", 22 | block: "https://gall.dcinside.com/ajax/minor_manager_board_ajax/update_avoid_list", 23 | blockMini: "https://gall.dcinside.com/ajax/mini_manager_board_ajax/update_avoid_list", 24 | setRecommend: "https://gall.dcinside.com/ajax/minor_manager_board_ajax/set_recommend", 25 | setRecommendMini: "https://gall.dcinside.com/ajax/mini_manager_board_ajax/set_recommend" 26 | }, 27 | comments: "https://gall.dcinside.com/board/comment/", 28 | comments_submit: "https://gall.dcinside.com/board/forms/comment_submit", 29 | dccon_comments_submit: "https://gall.dcinside.com/dccon/insert_icon", 30 | comment_remove: "https://gall.dcinside.com/board/comment/comment_delete_submit", 31 | dccon: { 32 | detail: "https://gall.dcinside.com/dccon/package_detail", 33 | info: "https://dccon.dcinside.com/index/get_info", 34 | buy: "https://dccon.dcinside.com/index/buy" 35 | } 36 | }; 37 | 38 | export const types = { 39 | MAJOR: "", 40 | MINOR: "mgallery", 41 | MINI: "mini", 42 | PERSON: "person" 43 | }; 44 | 45 | export const commentGallTypes: Record = { 46 | "": "G", 47 | mgallery: "M", 48 | mini: "MI", 49 | person: "PR" 50 | }; 51 | 52 | export const viewRegex = /\/board\/view\//g; 53 | export const mgall = /dcinside\.com\/mgallery/g; 54 | 55 | /** 56 | * 마이너 갤러리인지를 확인하여 boolean을 반환합니다. 57 | * @param url 확인할 URL 58 | */ 59 | export const checkMinor = (url?: string): boolean => /\.com\/mgallery/g.test(url || location.href); 60 | 61 | /** 62 | * 미니 갤러리인지를 확인하여 boolean을 반환합니다. 63 | * @param url 확인할 URL 64 | */ 65 | export const checkMini = (url?: string): boolean => /\.com\/mini/g.test(url || location.href); 66 | 67 | /** 68 | * 인물 갤러리인지를 확인하여 boolean을 반환합니다. 69 | * @param url 확인할 URL 70 | */ 71 | export const checkPerson = (url?: string): boolean => /\.com\/person/g.test(url || location.href); 72 | 73 | /** 74 | * URL에서 갤러리 종류를 확인하여 반환합니다. 75 | * 76 | * @param url 갤러리 종류를 확인할 URL. 77 | * @param extra 마이너 갤러리와 미니 갤러리에 붙일 URL suffix. 78 | */ 79 | export const galleryType = (url: string, extra?: string): string => { 80 | if (checkMinor(url)) return types.MINOR + (extra ?? ""); 81 | else if (checkMini(url)) return types.MINI + (extra ?? ""); 82 | else if (checkPerson(url)) return types.PERSON + (extra ?? ""); 83 | else return types.MAJOR; 84 | }; 85 | 86 | /** 87 | * URL에 /board/view가 포함되어 있을 경우 /board/lists로 바꿔줍니다. 88 | */ 89 | export const view = (url: string): string => { 90 | const type = { 91 | [types.MINI]: urls.gall.mini, 92 | [types.MINOR]: urls.gall.minor, 93 | [types.MAJOR]: urls.gall.major, 94 | [types.PERSON]: urls.gall.person 95 | }[galleryType(url)]; 96 | 97 | const urlParse = new URL(url); 98 | const queries = new URLSearchParams(url.replace(urlParse.origin + urlParse.pathname, "")); 99 | 100 | if (queries.has("no")) queries.delete("no"); 101 | 102 | return type + "board/lists?" + queries.toString(); 103 | }; 104 | 105 | export const mergeParamURL = (origin: string, getFrom: string): string => { 106 | const params: Record = {}; 107 | 108 | const originURL = new URL(origin); 109 | for (const [key, value] of originURL.searchParams) { 110 | params[key] = value; 111 | } 112 | 113 | const fromURL = new URL(getFrom); 114 | for (const [key, value] of fromURL.searchParams) { 115 | params[key] = value; 116 | } 117 | 118 | return "?" + new URLSearchParams(params).toString(); 119 | }; 120 | 121 | /** 122 | * URL에서 갤러리 종류를 확인하여 갤러리 종류 이름을 반환합니다. 123 | * (mgallery, mini, '') 124 | */ 125 | export const galleryTypeName = (url: string): string => commentGallTypes[galleryType(url)]; 126 | 127 | /** 128 | * 현재 URL의 query를 가져옵니다. 129 | * 130 | * @param name Query 이름 131 | */ 132 | export const queryString = (name: string): string | null => new URLSearchParams(location.search).get(name); 133 | 134 | export default { 135 | urls, 136 | types, 137 | viewRegex, 138 | mgall, 139 | checkMinor, 140 | checkMini, 141 | checkPerson, 142 | galleryType, 143 | view, 144 | mergeParamURL, 145 | galleryTypeName, 146 | queryString 147 | }; -------------------------------------------------------------------------------- /src/styles/layout.scss: -------------------------------------------------------------------------------- 1 | .refresherCompact { 2 | min-width: unset; 3 | 4 | .visit_bookmark { 5 | width: 95% !important; 6 | } 7 | 8 | .wrap_inner { 9 | width: unset; 10 | } 11 | 12 | .list_wrap, 13 | .view_wrap { 14 | min-width: unset; 15 | width: 100%; 16 | } 17 | 18 | .width1160 .dchead, 19 | .width1160 .gnb, 20 | .width1160 #container, 21 | .width1160 .info_policy, 22 | .width1160 .copyright { 23 | width: unset; 24 | } 25 | 26 | .dchead { 27 | height: 100px; 28 | } 29 | 30 | .list_wrap .dc_logo, 31 | .gnb_bar { 32 | display: none; 33 | } 34 | 35 | .width1160 .top_search { 36 | display: none; 37 | } 38 | 39 | .area_links { 40 | right: 13px; 41 | } 42 | 43 | .dc_logo { 44 | display: none; 45 | } 46 | 47 | .gall_exposure, 48 | .minor_ranking_box, 49 | .listwrap .issue_contentbox { 50 | display: none; 51 | } 52 | 53 | .right_content { 54 | float: initial; 55 | width: initial; 56 | 57 | .user_info, 58 | .user_option, 59 | .rightbanner1, 60 | #my_minor_pop, 61 | article { 62 | display: none; 63 | } 64 | } 65 | 66 | .list_array_option, 67 | .listwrap .visit_list, 68 | .listwrap .issuebox { 69 | width: 100% !important; 70 | } 71 | 72 | .listwrap .visit_history { 73 | width: 98.5%; 74 | } 75 | 76 | .listwrap .visit_list { 77 | display: contents; 78 | } 79 | 80 | .array_tab button { 81 | width: 72px; 82 | } 83 | 84 | .page_head h2 { 85 | font-size: 23px; 86 | } 87 | 88 | .left_content .visit_list li a { 89 | width: 60px; 90 | } 91 | 92 | .gall_issuebox { 93 | padding-top: unset; 94 | } 95 | 96 | .listwrap .minor_intro_box { 97 | padding: 0 0 0 2px; 98 | } 99 | 100 | main.listwrap { 101 | display: flex; 102 | } 103 | 104 | .width1160 .wrap_inner { 105 | margin-left: auto; 106 | margin-right: auto; 107 | max-width: 1000px; 108 | width: 95%; 109 | } 110 | 111 | .width1160 .dc_all { 112 | display: none; 113 | } 114 | 115 | .ad_bottom_list { 116 | display: none; 117 | } 118 | 119 | .input_write_tit { 120 | float: initial; 121 | } 122 | } 123 | 124 | .refresherCompactView { 125 | .cmt_write_box { 126 | display: flex; 127 | } 128 | 129 | .width1160 .left_content { 130 | width: unset !important; 131 | } 132 | 133 | .width1160 .wrap_inner { 134 | margin-left: auto; 135 | margin-right: auto; 136 | max-width: 1200px; 137 | width: 95%; 138 | } 139 | 140 | .cmt_txt_cont { 141 | margin-left: 20px; 142 | 143 | textarea { 144 | width: 100px; 145 | 146 | @media screen and (min-width: 1220px) { 147 | width: 956px !important; 148 | } 149 | 150 | @media screen and (min-width: 1145px) and (max-width: 1220px) { 151 | width: 882px !important; 152 | } 153 | 154 | @media screen and (min-width: 1070px) and (max-width: 1145px) { 155 | width: 808px !important; 156 | } 157 | 158 | @media screen and (min-width: 1015px) and (max-width: 1070px) { 159 | width: 758px !important; 160 | } 161 | 162 | @media screen and (min-width: 960px) and (max-width: 1015px) { 163 | width: 708px !important; 164 | } 165 | 166 | @media screen and (min-width: 850px) and (max-width: 960px) { 167 | width: 608px !important; 168 | } 169 | 170 | @media screen and (min-width: 740px) and (max-width: 850px) { 171 | width: 508px !important; 172 | } 173 | 174 | @media screen and (min-width: 630px) and (max-width: 740px) { 175 | width: 400px !important; 176 | } 177 | 178 | @media screen and (min-width: 520px) and (max-width: 630px) { 179 | width: 300px !important; 180 | } 181 | 182 | @media screen and (min-width: 410px) and (max-width: 520px) { 183 | width: 200px !important; 184 | } 185 | } 186 | } 187 | } 188 | 189 | .refresherHideGalleryView { 190 | .issue_wrap, 191 | #visit_history { 192 | display: none; 193 | } 194 | } 195 | 196 | .refresherHideUselessView section.right_content article { 197 | display: none; 198 | } 199 | 200 | .refresherHideUselessView.refresherPushToRight { 201 | .stickyunit, ._BTN_AD_ { 202 | display: none !important; 203 | } 204 | 205 | .list_array_option, 206 | .listwrap .left_content, 207 | .listwrap .issuebox { 208 | width: 1160px; 209 | } 210 | 211 | .listwrap .minor_intro_box, 212 | .listwrap .visit_list { 213 | width: 1000px; 214 | } 215 | 216 | .listwrap .visit_history { 217 | width: 1134px; 218 | } 219 | 220 | .list_wrap.width1160 .newvisit_history { 221 | width: unset; 222 | } 223 | } 224 | 225 | .refresherHideNtf { 226 | .btn_nftbox, 227 | .nft_informationwrap { 228 | display: none; 229 | } 230 | } 231 | 232 | .refresherHideGalleryImage { 233 | #zzbang_div { 234 | display: none; 235 | } 236 | } 237 | 238 | .refresherHideNotice { 239 | tr:has(em[class*=icon_notice]) { 240 | display: none; 241 | } 242 | } 243 | 244 | .refresherHideDCNotice { 245 | tr[class*=ub-content]:has(> td[user_name=운영자]) { 246 | display: none; 247 | } 248 | } 249 | 250 | .refresherHideGamemeca { 251 | tr[data-type=icon_fnews] { 252 | display: none; 253 | } 254 | } -------------------------------------------------------------------------------- /src/utils/user.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | 3 | import memo from "../core/memo"; 4 | import ip from "./ip"; 5 | import storage from "./storage"; 6 | import type {Nullable, ObjectEnum} from "./types"; 7 | 8 | export type UserType = 9 | | "UNFIXED" 10 | | "HALF_FIXED" 11 | | "FIXED" 12 | | "HALF_FIXED_SUB_MANAGER" 13 | | "FIXED_SUB_MANAGER" 14 | | "HALF_FIXED_MANAGER" 15 | | "FIXED_MANAGER"; 16 | 17 | const USERTYPE: ObjectEnum = { 18 | UNFIXED: "UNFIXED", 19 | HALF_FIXED: "HALF_FIXED", 20 | FIXED: "FIXED", 21 | HALF_FIXED_SUB_MANAGER: "HALF_FIXED_SUB_MANAGER", 22 | FIXED_SUB_MANAGER: "FIXED_SUB_MANAGER", 23 | HALF_FIXED_MANAGER: "HALF_FIXED_MANAGER", 24 | FIXED_MANAGER: "FIXED_MANAGER" 25 | }; 26 | 27 | let ratio: Record = {}; 28 | let ban: Record = {}; 29 | 30 | const initializeUserData = async (): Promise => { 31 | try { 32 | const [enable, checkRatio, checkPermBan] = await Promise.all([ 33 | storage.get("관리.enable"), 34 | storage.get("관리.checkRatio"), 35 | storage.get("관리.checkPermBan") 36 | ]); 37 | 38 | if (!enable) return; 39 | 40 | if (checkRatio) { 41 | const moduleData = await storage.module.get("관리"); 42 | ratio = moduleData?.["ratio"] ?? {}; 43 | } 44 | if (checkPermBan) { 45 | ban = (await storage.get("refresher.database.ban")) ?? {}; 46 | } 47 | } catch (e) { 48 | console.error("Failed to initialize user data:", e); 49 | } 50 | }; 51 | 52 | initializeUserData(); 53 | 54 | const FIXED_MANAGER_ICONS = ["fix_managernik.gif"]; 55 | const FIXED_SUB_MANAGER_ICONS = ["fix_sub_managernik.gif"]; 56 | const HALF_FIXED_SUB_MANAGER_ICONS = ["sub_managernik.gif"]; 57 | const HALF_FIXED_MANAGER_ICONS = ["managernik.gif"]; 58 | const FIXED_ICONS = [ 59 | "fix_nik.gif", 60 | "nftcon_fix.png", 61 | "dc20th_wgallcon4.png", 62 | "w_app_gonick_16.png", 63 | "nftmdcon_fix.png", 64 | "gnftmdcon_fix.gif", 65 | "bestcon_fix.png" 66 | ]; 67 | const HALF_FIXED_ICONS = [ 68 | "nik.gif", 69 | "nftcon.png", 70 | "dc20th_wgallcon.png", 71 | "w_app_nogonick_16.png", 72 | "nftmdcon.png", 73 | "gnftmdcon.gif", 74 | "bestcon.png" 75 | ]; 76 | 77 | export const getType = (icon: string | null): UserType => { 78 | if (!icon) { 79 | return USERTYPE.UNFIXED; 80 | } 81 | 82 | if (FIXED_MANAGER_ICONS.some((suffix) => icon.endsWith(suffix))) { 83 | return USERTYPE.FIXED_MANAGER; 84 | } 85 | 86 | if (FIXED_SUB_MANAGER_ICONS.some((suffix) => icon.endsWith(suffix))) { 87 | return USERTYPE.FIXED_SUB_MANAGER; 88 | } 89 | 90 | if (HALF_FIXED_SUB_MANAGER_ICONS.some((suffix) => icon.endsWith(suffix))) { 91 | return USERTYPE.HALF_FIXED_SUB_MANAGER; 92 | } 93 | 94 | if (HALF_FIXED_MANAGER_ICONS.some((suffix) => icon.endsWith(suffix))) { 95 | return USERTYPE.HALF_FIXED_MANAGER; 96 | } 97 | 98 | if (FIXED_ICONS.some((suffix) => icon.endsWith(suffix))) { 99 | return USERTYPE.FIXED; 100 | } 101 | 102 | if (HALF_FIXED_ICONS.some((suffix) => icon.endsWith(suffix))) { 103 | return USERTYPE.HALF_FIXED; 104 | } 105 | 106 | return USERTYPE.UNFIXED; 107 | }; 108 | 109 | export class User { 110 | ip_data: Nullable; 111 | ip_color: Nullable; 112 | type: UserType; 113 | memo: Nullable; 114 | ratio: Nullable; 115 | ban: Nullable; 116 | 117 | private __ip: Nullable; 118 | 119 | constructor( 120 | public nick: string, 121 | public id: Nullable, 122 | ip: Nullable, 123 | public icon: Nullable 124 | ) { 125 | this.__ip = null; 126 | this.ip_data = null; 127 | this.ip_color = null; 128 | 129 | this.nick = nick; 130 | this.id = id; 131 | this.ip = ip; 132 | 133 | this.icon = icon; 134 | this.type = getType(this.icon); 135 | this.memo = null; 136 | this.ratio = null; 137 | this.ban = null; 138 | 139 | this.getMemo(); 140 | this.getRatio(); 141 | this.getBan(); 142 | } 143 | 144 | get ip(): string | null { 145 | return this.__ip; 146 | } 147 | 148 | set ip(v: string | null) { 149 | this.__ip = v; 150 | 151 | if (v === null) return; 152 | 153 | const ispData = ip.ISPData(v); 154 | this.ip_color = ispData.color; 155 | this.ip_data = ip.format(ispData); 156 | } 157 | 158 | static fromDom(dom: HTMLElement | null): User { 159 | const user = new User("", null, null, null); 160 | const $dom = $(dom); 161 | 162 | if (!dom || !$dom.length) return user; 163 | 164 | user.nick = dom.dataset.nick || "오류"; 165 | user.id = dom.dataset.uid || null; 166 | 167 | const ip = dom.dataset.ip; 168 | user.ip = ip ? String(ip) : null; 169 | 170 | user.icon = user.id ? $dom.find("a.writer_nikcon img").attr("src") : null; 171 | user.type = getType(user.icon); 172 | 173 | user.getMemo(); 174 | user.getRatio(); 175 | user.getBan(); 176 | 177 | return user; 178 | } 179 | 180 | getMemo(): void { 181 | this.memo = memo.get("UID", this.id) ?? memo.get("IP", this.ip) ?? memo.get("NICK", this.nick); 182 | } 183 | 184 | getRatio(): void { 185 | if (!this.id) return; 186 | 187 | const r = ratio?.[this.id]; 188 | 189 | if (!r) return; 190 | 191 | this.ratio = `${r.article}/${r.comment}`; 192 | } 193 | 194 | getBan(): void { 195 | if (!this.id) return; 196 | 197 | const bannedFrom = Object.entries(ban) 198 | .filter(([, userIds]) => userIds.includes(this.id!)) 199 | .map(([key]) => key); 200 | 201 | if (bannedFrom.length === 0) return; 202 | 203 | this.ban = bannedFrom.join(", "); 204 | } 205 | 206 | isLogout(): boolean { 207 | return this.ip !== null; 208 | } 209 | 210 | isMember(): boolean { 211 | return this.id !== null; 212 | } 213 | } 214 | 215 | export default { 216 | getType, 217 | User 218 | }; -------------------------------------------------------------------------------- /src/components/toast.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 77 | 78 | -------------------------------------------------------------------------------- /src/core/modules.ts: -------------------------------------------------------------------------------- 1 | import {sendToBackground} from "@plasmohq/messaging"; 2 | 3 | import http from "../utils/http"; 4 | import ip from "../utils/ip"; 5 | import storage from "../utils/storage"; 6 | import block from "./block"; 7 | import communicate from "./communicate"; 8 | import eventBus from "./eventbus"; 9 | import filter from "./filtering"; 10 | import Frame from "./frame"; 11 | import memo from "./memo"; 12 | import settings from "./settings"; 13 | 14 | type ModuleItem = ValueOf; 15 | 16 | export type ModuleStore = Record; 17 | 18 | const UTILS: ItemToRefresherMap = { 19 | filter, 20 | Frame, 21 | eventBus, 22 | http, 23 | ip, 24 | block, 25 | memo 26 | }; 27 | 28 | const module_store: ModuleStore = {}; 29 | 30 | const runModule = (module: RefresherModule) => { 31 | const plugins: ModuleItem[] = Array.isArray(module.require) 32 | ? (module.require as (keyof ItemToRefresherMap)[]).map((require) => UTILS[require]) 33 | : []; 34 | 35 | // @ts-ignore 36 | if (typeof module.func === "function") module.func(...plugins); 37 | }; 38 | 39 | const revokeModule = (module: RefresherModule) => { 40 | if (typeof module.revoke === "function") { 41 | const plugins: ModuleItem[] = Array.isArray(module.require) 42 | ? (module.require as (keyof ItemToRefresherMap)[]).map((require) => UTILS[require]) 43 | : []; 44 | 45 | // @ts-ignore 46 | module.revoke(...plugins); 47 | } 48 | 49 | if (typeof module.memory === "object") { 50 | for (const key in module.memory) { 51 | module.memory[key] = undefined; 52 | } 53 | } 54 | }; 55 | 56 | export const modules = { 57 | lists: (): ModuleStore => module_store, 58 | load: (module: RefresherModule): Promise => modules.register(module), 59 | register: async (module: RefresherModule): Promise => { 60 | if (!module) throw "Module is not defined."; 61 | if (module_store[module.name]) throw `${module.name} is already registered.`; 62 | 63 | const promises: Promise[] = []; 64 | 65 | promises.push( 66 | storage.get(`${module.name}.enable`).then((enable) => { 67 | if (enable === undefined) { 68 | storage.set(`${module.name}.enable`, module.default_enable); 69 | module.enable = module.default_enable; 70 | return; 71 | } 72 | 73 | module.enable = enable; 74 | }) 75 | ); 76 | 77 | if (typeof module.settings === "object") { 78 | module.status ??= {}; 79 | 80 | promises.push( 81 | ...Object.entries(module.settings).map(async ([key, value]) => { 82 | module.status[key] = await settings.load(module.name, key, value); 83 | }) 84 | ); 85 | } 86 | 87 | if (typeof module.data === "object") { 88 | promises.push( 89 | storage.module.get(module.name).then((data) => { 90 | // @ts-ignore 91 | module.data = new Proxy(data ?? module.data ?? {}, { 92 | set(target, p, newValue, receiver) { 93 | const result = Reflect.set(target, p, newValue, receiver); 94 | storage.module.setGlobal(module.name, target); 95 | return result; 96 | }, 97 | 98 | deleteProperty(target, p) { 99 | const result = Reflect.deleteProperty(target, p); 100 | storage.module.setGlobal(module.name, target); 101 | return result; 102 | } 103 | }); 104 | }) 105 | ); 106 | } 107 | 108 | module_store[module.name] = module; 109 | 110 | await Promise.all(promises); 111 | 112 | sendToBackground({ 113 | name: "store", 114 | body: { 115 | action: "update", 116 | type: "modules", 117 | data: { 118 | module_store: JSON.parse(JSON.stringify(module_store)), 119 | settings_store: JSON.parse(JSON.stringify(settings.dump())) 120 | } 121 | } 122 | }); 123 | 124 | if (!module.enable || module.url?.test(location.href) === false) return; 125 | 126 | runModule(module); 127 | } 128 | }; 129 | 130 | export default modules; 131 | 132 | communicate.addHook("updateModuleStatus", (data) => { 133 | module_store[data.name].enable = data.value as boolean; 134 | storage.set(`${data.name}.enable`, data.value); 135 | 136 | sendToBackground({ 137 | name: "store", 138 | body: { 139 | action: "update", 140 | type: "modules", 141 | data: { 142 | module_store: JSON.parse(JSON.stringify(module_store)) 143 | } 144 | } 145 | }); 146 | 147 | if (data.value) { 148 | runModule(module_store[data.name]); 149 | return; 150 | } 151 | 152 | revokeModule(module_store[data.name]); 153 | }); 154 | 155 | communicate.addHook("updateSettingValue", (data) => { 156 | settings.setStore(data.name, data.key, data.value); 157 | }); 158 | 159 | communicate.addHook("executeShortcut", (data) => { 160 | for (const key of Object.keys(module_store)) { 161 | if ( 162 | module_store[key] && 163 | typeof module_store[key].shortcuts === "object" && 164 | typeof module_store[key].shortcuts![data] === "function" 165 | ) { 166 | module_store[key].shortcuts![data].bind(module_store[key])(); 167 | } 168 | } 169 | }); 170 | 171 | eventBus.on("refresherUpdateSetting", (mod: string, key: string, value: unknown) => { 172 | const module = module_store[mod]; 173 | 174 | if (module !== undefined) { 175 | module.status ??= {}; 176 | module.status[key] = value; 177 | } 178 | 179 | if (!module.enable || !module.update || typeof module.update[key] !== "function") return; 180 | 181 | const plugins: ModuleItem[] = []; 182 | 183 | if (Array.isArray(module.require)) { 184 | for (const require of module.require as (keyof ItemToRefresherMap)[]) { 185 | plugins.push(UTILS[require]); 186 | } 187 | } 188 | 189 | module.update[key].bind(module)(value, ...plugins); 190 | }); -------------------------------------------------------------------------------- /src/core/frameComponent.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 151 | 152 | -------------------------------------------------------------------------------- /src/modules/layout.ts: -------------------------------------------------------------------------------- 1 | import $ from "cash-dom"; 2 | 3 | const hideSticky = (hide: boolean) => { 4 | $(".stickyunit").css("display", hide ? "none" : "initial"); 5 | }; 6 | 7 | const updateWindowSize = (forceActive: boolean, active: number | string, width: number) => { 8 | if (typeof active === "string") active = Number(active); 9 | 10 | const $document = $(document.documentElement); 11 | 12 | const isView = location.href.includes("board/view"); 13 | 14 | if (forceActive || active >= width) { 15 | hideSticky(true); 16 | 17 | if (!$document.hasClass("refresherCompact")) { 18 | if (isView) $document.addClass("refresherCompactView"); 19 | 20 | $document.addClass("refresherCompact"); 21 | } 22 | } else { 23 | hideSticky(false); 24 | $document.removeClass("refresherCompact"); 25 | } 26 | }; 27 | 28 | export default { 29 | name: "레이아웃 수정", 30 | description: "디시 레이아웃을 변경할 수 있도록 도와줍니다.", 31 | status: {}, 32 | memory: { 33 | resize: null 34 | }, 35 | enable: true, 36 | default_enable: true, 37 | settings: { 38 | activePixel: { 39 | name: "컴팩트 모드 활성화 조건", 40 | desc: "브라우저 가로가 이 값 보다 작을 경우 컴팩트 모드를 활성화합니다.", 41 | type: "range", 42 | default: 900, 43 | min: 100, 44 | max: screen.width, 45 | step: 1, 46 | unit: "px", 47 | value: 1 48 | }, 49 | forceCompact: { 50 | name: "컴팩트 모드 강제 사용", 51 | desc: "항상 컴팩트 모드를 사용하도록 설정합니다.", 52 | type: "check", 53 | default: false 54 | }, 55 | useCompactModeOnView: { 56 | name: "컴팩트 모드 강제 사용", 57 | desc: "게시글 보기에서도 컴팩트 모드를 사용하도록 설정합니다.", 58 | type: "check", 59 | default: true 60 | }, 61 | hideGalleryView: { 62 | name: "갤러리 뷰 숨기기", 63 | desc: "갤러리 정보, 최근 방문 갤러리 영역을 숨깁니다.", 64 | type: "check", 65 | default: false 66 | }, 67 | hideUselessView: { 68 | name: "잡다 링크 숨기기", 69 | desc: "이슈줌, 타갤 개념글, 뉴스, 힛갤등의 컨텐츠를 오른쪽 영역에서 숨깁니다.", 70 | type: "check", 71 | default: false 72 | }, 73 | hideNft: { 74 | name: "NFT 숨기기", 75 | desc: "NFT 관련 내용을 숨깁니다.", 76 | type: "check", 77 | default: false 78 | }, 79 | hideGalleryImage: { 80 | name: "갤러리 대문 숨기기", 81 | desc: "갤러리 대문을 숨깁니다.", 82 | type: "check", 83 | default: false 84 | }, 85 | pushToRight: { 86 | name: "본문 영역 전체로 확장", 87 | desc: `"잡다 링크 숨기기" 옵션이 켜진 경우 본문 영역을 확장합니다.`, 88 | type: "check", 89 | default: false 90 | }, 91 | removeNotice: { 92 | name: "갤러리 공지 숨기기", 93 | desc: "글 목록에서 공지사항을 숨깁니다.", 94 | type: "check", 95 | default: false 96 | }, 97 | removeDCNotice: { 98 | name: "디시 공지 숨기기", 99 | desc: "글 목록에서 운영자의 게시글을 숨깁니다.", 100 | type: "check", 101 | default: false 102 | }, 103 | removeGamemeca: { 104 | name: "게임메카 숨기기", 105 | desc: "글 목록에서 게임메카 게시글을 숨깁니다.", 106 | type: "check", 107 | default: false 108 | } 109 | }, 110 | update: { 111 | activePixel(value: number) { 112 | updateWindowSize(this.status.forceCompact, value, innerWidth); 113 | }, 114 | forceCompact(value: boolean) { 115 | updateWindowSize(value, this.status.activePixel, innerWidth); 116 | }, 117 | hideGalleryView(value: boolean) { 118 | $(document.documentElement).toggleClass("refresherHideGalleryView", value); 119 | }, 120 | hideUselessView(value: boolean) { 121 | $(document.documentElement).toggleClass("refresherHideUselessView", value); 122 | }, 123 | hideNft(value: boolean) { 124 | $(document.documentElement).toggleClass("refresherHideNtf", value); 125 | }, 126 | hideGalleryImage(value: boolean) { 127 | $(document.documentElement).toggleClass("refresherHideGalleryImage", value); 128 | }, 129 | pushToRight(value: boolean) { 130 | hideSticky(value); 131 | $(document.documentElement).toggleClass("refresherPushToRight", value); 132 | }, 133 | removeNotice(value: boolean) { 134 | if (new URL(location.href).searchParams.get("exception_mode") === "notice") return; 135 | 136 | $(document.documentElement).toggleClass("refresherHideNotice", value); 137 | }, 138 | removeDCNotice(value: boolean) { 139 | $(document.documentElement).toggleClass("refresherHideDCNotice", value); 140 | }, 141 | removeGamemeca(value: boolean) { 142 | $(document.documentElement).toggleClass("refresherHideGamemeca", value); 143 | } 144 | }, 145 | require: [], 146 | func() { 147 | const isPageView = location.href.includes("board/view"); 148 | 149 | if (!isPageView || (isPageView && this.status.useCompactModeOnView)) { 150 | this.memory.resize = () => updateWindowSize(this.status.forceCompact, this.status.activePixel, innerWidth); 151 | 152 | window.addEventListener("resize", this.memory.resize); 153 | this.memory.resize(); 154 | } 155 | 156 | this.update.hideGalleryView.bind(this)(this.status.hideGalleryView); 157 | this.update.hideUselessView.bind(this)(this.status.hideUselessView); 158 | this.update.hideNft.bind(this)(this.status.hideNft); 159 | this.update.hideGalleryImage.bind(this)(this.status.hideGalleryImage); 160 | this.update.pushToRight.bind(this)(this.status.pushToRight); 161 | this.update.removeNotice.bind(this)(this.status.removeNotice); 162 | this.update.removeDCNotice.bind(this)(this.status.removeDCNotice); 163 | this.update.removeGamemeca.bind(this)(this.status.removeGamemeca); 164 | }, 165 | revoke() { 166 | window.removeEventListener("resize", this.memory.resize!); 167 | 168 | this.update.hideGalleryView.bind(this)(false); 169 | this.update.hideUselessView.bind(this)(false); 170 | this.update.hideNft.bind(this)(false); 171 | this.update.hideGalleryImage.bind(this)(false); 172 | this.update.pushToRight.bind(this)(false); 173 | this.update.removeNotice.bind(this)(false); 174 | this.update.removeDCNotice.bind(this)(false); 175 | } 176 | } as RefresherModule<{ 177 | memory: { 178 | resize: (() => void) | null; 179 | }; 180 | settings: { 181 | activePixel: RefresherRangeSettings; 182 | forceCompact: RefresherCheckSettings; 183 | useCompactModeOnView: RefresherCheckSettings; 184 | hideGalleryView: RefresherCheckSettings; 185 | hideUselessView: RefresherCheckSettings; 186 | hideNft: RefresherCheckSettings; 187 | hideGalleryImage: RefresherCheckSettings; 188 | pushToRight: RefresherCheckSettings; 189 | removeNotice: RefresherCheckSettings; 190 | removeDCNotice: RefresherCheckSettings; 191 | removeGamemeca: RefresherCheckSettings; 192 | }; 193 | update: { 194 | activePixel(value: number): void; 195 | forceCompact(value: boolean): void; 196 | hideGalleryView(value: boolean): void; 197 | hideUselessView(value: boolean): void; 198 | hideNft(value: boolean): void; 199 | hideGalleryImage(value: boolean): void; 200 | pushToRight(value: boolean): void; 201 | removeNotice(value: boolean): void; 202 | removeDCNotice(value: boolean): void; 203 | removeGamemeca(value: boolean): void; 204 | }; 205 | require: []; 206 | }>; 207 | -------------------------------------------------------------------------------- /src/components/comment.vue: -------------------------------------------------------------------------------- 1 |