diff --git a/builder/package.json b/builder/package.json index cfa7618..04914fa 100644 --- a/builder/package.json +++ b/builder/package.json @@ -8,26 +8,24 @@ "debug": "tsx source/debug.ts", "clean": "rm -rf dist && rm -rf .buildcache" }, - "dependencies": { - "@types/node": "^24.12.0" - }, "devDependencies": { - "@adguard/agtree": "^4.0.1", + "@adguard/agtree": "^4.0.4", "@npmcli/package-json": "^7.0.5", + "@types/node": "^24.12.0", "@types/npmcli__package-json": "^4.0.4", "@types/semver": "^7.7.1", - "@typescript-eslint/eslint-plugin": "^8.57.0", - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", "@typescriptprime/parsing": "^2.0.0", "@typescriptprime/securereq": "^1.2.0", "chokidar": "^5.0.0", - "esbuild": "^0.27.3", - "eslint": "^10.0.3", + "esbuild": "^0.27.4", + "eslint": "^10.1.0", "semver": "^7.7.4", - "tldts": "^7.0.25", + "tldts": "^7.0.27", "tsx": "^4.21.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.57.0", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.0", "zod": "^4.3.6" } } diff --git a/builder/source/banner/index.ts b/builder/source/banner/index.ts index d8959d7..624f500 100644 --- a/builder/source/banner/index.ts +++ b/builder/source/banner/index.ts @@ -28,6 +28,7 @@ export function CreateBanner(Options: BannerOptions): string { BannerString += `// @author ${Options.Author}\n` BannerString += '//\n' BannerString += '// @grant unsafeWindow\n' + BannerString += '// @grant GM.xmlHttpRequest\n' BannerString += '// @run-at document-start\n' BannerString += '//\n' BannerString += `// @description ${Options.Description['en']}\n` diff --git a/builder/source/build.ts b/builder/source/build.ts index 1c51eac..431b246 100644 --- a/builder/source/build.ts +++ b/builder/source/build.ts @@ -1,6 +1,7 @@ import * as ESBuild from 'esbuild' import * as Zod from 'zod' import * as Process from 'node:process' +import * as Path from 'node:path' import PackageJson from '@npmcli/package-json' import { CreateBanner } from './banner/index.js' import { SafeInitCwd } from './utils/safe-init-cwd.js' @@ -44,14 +45,25 @@ export async function Build(OptionsParam?: BuildOptions): Promise { } }) + const WorkerCode = await ESBuild.build({ + entryPoints: [Path.resolve(ProjectRoot, 'userscript', 'source', 'ocr-worker.ts')], + bundle: true, + minify: Options.Minify, + write: false, + target: ['es2024', 'chrome119', 'firefox142', 'safari26'] + }) + await ESBuild.build({ - entryPoints: [ProjectRoot + '/userscript/source/index.ts'], + entryPoints: [Path.resolve(ProjectRoot, 'userscript', 'source', 'index.ts')], bundle: true, minify: Options.Minify, outfile: `${ProjectRoot}/dist/NamuLink${Options.BuildType === 'development' ? '.dev' : ''}.user.js`, banner: { js: Banner }, - target: ['es2024', 'chrome119', 'firefox142', 'safari26'] + target: ['es2024', 'chrome119', 'firefox142', 'safari26'], + define: { + __OCR_WORKER_CODE__: JSON.stringify(WorkerCode.outputFiles[0].text) + } }) } \ No newline at end of file diff --git a/builder/source/debug.ts b/builder/source/debug.ts index ce6e330..e97cd10 100644 --- a/builder/source/debug.ts +++ b/builder/source/debug.ts @@ -8,7 +8,7 @@ import { Build } from './build.js' import { SafeInitCwd } from './utils/safe-init-cwd.js' let ProjectRoot = SafeInitCwd({ Cwd: Process.cwd(), InitCwd: Process.env.INIT_CWD }) -const WatchingGlob = []; +const WatchingGlob: string[] = []; ['builder/', 'userscript/', ''].forEach(Dir => { WatchingGlob.push(...Fs.globSync(`${ProjectRoot}/${Dir}source/**/*.ts`)) WatchingGlob.push(...Fs.globSync(`${ProjectRoot}/${Dir}source/**/*.json`)) @@ -18,11 +18,13 @@ const Watcher = Chokidar.watch([...WatchingGlob], { ignored: '**/node_modules/**', }) -let BuildCooldownTimer: NodeJS.Timeout = null +let BuildCooldownTimer: NodeJS.Timeout | null = null let ShouldPreventHTTPResponse = false let Version: number = 0 Watcher.on('all', async (WatcherEvent, WatcherPath) => { - clearTimeout(BuildCooldownTimer) + if (BuildCooldownTimer) { + clearTimeout(BuildCooldownTimer) + } BuildCooldownTimer = setTimeout(async () => { console.log(`Detected file change (${WatcherEvent}):`, WatcherPath) ShouldPreventHTTPResponse = true diff --git a/builder/source/utils/http-server.ts b/builder/source/utils/http-server.ts index bd53ef2..621062b 100644 --- a/builder/source/utils/http-server.ts +++ b/builder/source/utils/http-server.ts @@ -26,7 +26,7 @@ export function RunDebugServer(Port: number, FileName: string[], ShouldPreventHT Res.writeHead(404) Res.end() return - } else if (!IsLoopBack(Req.socket.remoteAddress)) { + } else if (!IsLoopBack(Req.socket.remoteAddress ?? '')) { Res.writeHead(403) Res.end() return diff --git a/builder/tsconfig.json b/builder/tsconfig.json index ed149fb..26341a4 100644 --- a/builder/tsconfig.json +++ b/builder/tsconfig.json @@ -2,5 +2,8 @@ "extends": "../tsconfig.json", "include": [ "source/**/*.ts" - ] + ], + "compilerOptions": { + "types": ["node"] + } } \ No newline at end of file diff --git a/package.json b/package.json index 0025613..325c213 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "builder" ], "devDependencies": { - "@typescript-eslint/eslint-plugin": "^8.57.0", - "@typescript-eslint/parser": "^8.57.0", - "eslint": "^10.0.3", - "typescript-eslint": "^8.57.0" + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^10.1.0", + "typescript-eslint": "^8.58.0" } } diff --git a/tsconfig.json b/tsconfig.json index ac06520..c2e7c82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,6 @@ "target": "ES2024", "moduleResolution": "NodeNext", "removeComments": false, - "alwaysStrict": false, "skipLibCheck": true, "paths": { "@builder/*": ["./builder/source/*"], diff --git a/userscript/VM.d.ts b/userscript/VM.d.ts new file mode 100644 index 0000000..b09a8a0 --- /dev/null +++ b/userscript/VM.d.ts @@ -0,0 +1 @@ +import '@violentmonkey/types' \ No newline at end of file diff --git a/userscript/package.json b/userscript/package.json index a20ad71..6398c9c 100644 --- a/userscript/package.json +++ b/userscript/package.json @@ -6,10 +6,11 @@ "lint": "tsc --noEmit && eslint **/*.ts" }, "devDependencies": { - "@types/web": "^0.0.342", - "@typescript-eslint/eslint-plugin": "^8.57.0", - "@typescript-eslint/parser": "^8.57.0", - "eslint": "^10.0.3", - "typescript-eslint": "^8.57.0" + "@types/web": "^0.0.345", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "@violentmonkey/types": "^0.3.2", + "eslint": "^10.1.0", + "typescript-eslint": "^8.58.0" } } diff --git a/userscript/source/dom-await.ts b/userscript/source/dom-await.ts new file mode 100644 index 0000000..208bcfc --- /dev/null +++ b/userscript/source/dom-await.ts @@ -0,0 +1,25 @@ +export function WaitForElement(Selector: string, Root: HTMLElement | Document = document.documentElement): Promise { + return new Promise((Resolve) => { + const Found = Root.querySelector(Selector) + + if (Found && Found instanceof HTMLElement) { + Resolve(Found) + return + } + + const Observer = new MutationObserver(() => { + const El = Root.querySelector(Selector) + + if (El && El instanceof HTMLElement) { + Observer.disconnect() + Resolve(El) + } + }) + + Observer.observe(Root, { + subtree: true, + childList: true, + attributes: true + }) + }) +} \ No newline at end of file diff --git a/userscript/source/index.ts b/userscript/source/index.ts index 7a4c844..d89727c 100644 --- a/userscript/source/index.ts +++ b/userscript/source/index.ts @@ -14,160 +14,15 @@ declare const unsafeWindow: unsafeWindow const Win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window -export function RunNamuLinkUserscript(BrowserWindow: typeof window, UserscriptName: string = 'NamuLink'): void { - const OriginalFunctionPrototypeCall = BrowserWindow.Function.prototype.call - const OriginalReflectApply = BrowserWindow.Reflect.apply - const OriginalObjectDefineProperty = BrowserWindow.Object.defineProperty - const OriginalProxy = BrowserWindow.Proxy - const OriginalObjectGetOwnPropertyDescriptor = BrowserWindow.Object.getOwnPropertyDescriptor - - const PL2MajorFuncCallPatterns: RegExp[][] = [[ - /function *\( *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *\) *{ *return *[A-Za-z.-9]+/, - /, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *\) *{ *return *[A-Za-z.-9]+ *\( *[0-9a-fx *+-]+ *, *[A-Za-z.-9]+ *, *[A-Za-z.-9]+ *, *[0-9a-fx *+-]+/, - /return *[A-Za-z.-9]+ *\( *[0-9a-fx *+-]+ *, *[A-Za-z.-9]+ *, *[A-Za-z.-9]+ *, *[0-9a-fx *+-]+ *,[A-Za-z.-9]+ *, *[A-Za-z.-9]+ * *\) *; *}/ - ], [ - /function *[A-Za-z0-9]+ *\( *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *\) *{ *return *[A-Za-z.-9]+/, - /, *[A-Za-z0-9]+ *, *[A-Za-z0-9]+ *\) *{ *return *[A-Za-z.-9]+ *\( *[0-9a-fx *+-]+ *, *[A-Za-z.-9]+ *, *[A-Za-z.-9]+ *, *[0-9a-fx *+-]+/, - /return *[A-Za-z.-9]+ *\( *[0-9a-fx *+-]+ *, *[A-Za-z.-9]+ *, *[A-Za-z.-9]+ *, *[0-9a-fx *+-]+ *,[A-Za-z.-9]+ *, *[A-Za-z.-9]+ * *\) *; *}/ - ]] - - function PowerLinkElementFromArg(Arg: unknown): HTMLElement | null { - if (typeof Arg !== 'object' || Arg === null) return null - if (typeof OriginalReflectApply(OriginalObjectGetOwnPropertyDescriptor, BrowserWindow.Object, [Arg, '_'])?.get === 'function') return null - - const Visited = new Set() - let Current = (Arg as Record)['_'] - - while (typeof Current === 'object' && Current !== null) { - if (Visited.has(Current)) break - Visited.add(Current) - - const VNode = (Current as Record)['vnode'] - if (typeof VNode === 'object' && VNode !== null) { - const Element = (VNode as Record)['el'] - if (Element instanceof HTMLElement) return Element - } - - Current = (Current as Record)['parent'] - } - - return null - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - function PowerLinkRenderFromArg(Arg: unknown): Function | null { - if (typeof Arg !== 'object' || Arg === null) return null - if (typeof OriginalReflectApply(OriginalObjectGetOwnPropertyDescriptor, BrowserWindow.Object, [Arg, '_'])?.get === 'function') return null - - const Visited = new Set() - let Current: unknown = (Arg as Record)['_'] - - while (typeof Current === 'object' && Current !== null) { - if (Visited.has(Current)) break - Visited.add(Current) - - const Render = (Current as Record)['render'] - if (typeof Render === 'function') return Render - - Current = (Current as Record)['parent'] - } - - return null - } - function PowerLinkElementFromArgParent(Arg: unknown): HTMLElement | null { - if (typeof Arg !== 'object' || Arg === null) return null - if (typeof OriginalReflectApply(OriginalObjectGetOwnPropertyDescriptor, BrowserWindow.Object, [Arg, '_'])?.get === 'function') return null - - const Visited = new Set() - let Current = (Arg as Record)['_'] - - while (typeof Current === 'object' && Current !== null) { - if (Visited.has(Current)) break - Visited.add(Current) - - const Parent = (Current as Record)['parent'] - if (typeof Parent === 'object' && Parent !== null) { - const ParentVNode = (Parent as Record)['vnode'] - if (typeof ParentVNode === 'object' && ParentVNode !== null) { - const ParentElement = (ParentVNode as Record)['el'] - if (ParentElement instanceof HTMLElement) return ParentElement - } - } - - const VNode = (Current as Record)['vnode'] - if (typeof VNode === 'object' && VNode !== null) { - const Element = (VNode as Record)['el'] - if (Element instanceof HTMLElement) return Element - } - Current = Parent - } - return null - } - - const MinRatio = 0.35 - const MaxRatio = 0.75 - const EpsilonRatio = 0.04 - - function ParseCssFloat(Value: string): number { - return Number.parseFloat(Value) || 0 - } +import { AttachVueSettledEvents } from './vuejsawait.js' +import { WaitForElement } from './dom-await.js' +import { CreateOcrWorkerClient } from './ocr-client.js' - let CommentContainer: Element = null - let InHook = false - BrowserWindow.Function.prototype.call = new Proxy(OriginalFunctionPrototypeCall, { - apply(Target: typeof Function.prototype.call, ThisArg: unknown, Args: unknown[]) { - // Prevent infinite recursion when the hook itself calls Function.prototype.call - if (InHook) { - return OriginalReflectApply(Target, ThisArg, Args) - } - InHook = true - - const Stringified = String(ThisArg) - if (Stringified.length < 500 && PL2MajorFuncCallPatterns.filter(Patterns => Patterns.filter(Pattern => Pattern.test(Stringified)).length === Patterns.length).length === 1) { - let PL2Element: HTMLElement | null = PowerLinkElementFromArgParent(Args[6]) - if (PL2Element !== null && [...PL2Element.querySelectorAll('*')].filter(Child => { - if (!(Child instanceof HTMLElement)) return false - let PL2TitleHeight = Child.getClientRects()[0]?.height ?? 0 - let PL2TitleMarginBottom = Math.max(ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')), - ParseCssFloat(getComputedStyle(Child).getPropertyValue('margin-bottom'))) - return PL2TitleHeight > 0 && PL2TitleMarginBottom >= PL2TitleHeight * (MinRatio - EpsilonRatio) && PL2TitleMarginBottom <= PL2TitleHeight * (MaxRatio + EpsilonRatio) - }).length >= 1) { - console.debug(`[${UserscriptName}]: Function.prototype.call called for PowerLink Skeleton:`, ThisArg) - CommentContainer = PL2Element - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolder')) - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolderMobile')) - InHook = false - return OriginalReflectApply(Target, () => {}, []) - } - console.debug(`[${UserscriptName}]: Matched Function.prototype.call called, but not for PowerLink Skeleton:`, ThisArg) - } - InHook = false - return OriginalReflectApply(Target, ThisArg, Args) - } - }) +// eslint-disable-next-line @typescript-eslint/naming-convention +declare const __OCR_WORKER_CODE__: string - BrowserWindow.Object.defineProperty = new Proxy(OriginalObjectDefineProperty, { - apply(Target: typeof Object.defineProperty, ThisArg: undefined, Args: Parameters) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - let VuejsRender: Function | null = PowerLinkRenderFromArg(Args[0]) - let PL2Element: HTMLElement | null = PowerLinkElementFromArgParent(Args[0]) - let Stringified = String(VuejsRender ?? '') - if (VuejsRender !== null && PL2Element !== null && Stringified.length < 500 && - PL2MajorFuncCallPatterns.filter(Patterns => Patterns.filter(Pattern => Pattern.test(Stringified)).length === Patterns.length).length === 1 && - [...PL2Element.querySelectorAll('*')].filter(Child => { - if (!(Child instanceof HTMLElement)) return false - let PL2TitleHeight = Child.getClientRects()[0]?.height ?? 0 - let PL2TitleMarginBottom = Math.max(ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')), - ParseCssFloat(getComputedStyle(Child).getPropertyValue('margin-bottom'))) - return PL2TitleHeight > 0 && PL2TitleMarginBottom >= PL2TitleHeight * (MinRatio - EpsilonRatio) && PL2TitleMarginBottom <= PL2TitleHeight * (MaxRatio + EpsilonRatio) - }).length >= 1 - ) { - console.debug(`[${UserscriptName}]: Restoring renderer.call for detected PowerLink skeleton:`, Args[0]) - VuejsRender.call = Function.prototype.call - return - } - return OriginalReflectApply(Target, ThisArg, Args) - } - }) +export async function RunNamuLinkUserscript(BrowserWindow: typeof window, UserscriptName: string = 'NamuLink'): Promise { + const OriginalReflectApply = BrowserWindow.Reflect.apply let PL2AfterLoadInitTimerPatterns: RegExp[][] = [[ /\( *\) *=> *{ *var *_0x[0-9a-z]+ *= *a0_0x[0-9a-f]+ *; *this\[ *_0x[a-z0-9]+\( *0x[0-9a-f]+ *\) *\]\(\); *}/, @@ -189,358 +44,65 @@ export function RunNamuLinkUserscript(BrowserWindow: typeof window, UserscriptNa } }) - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - function PowerLinkOverrideRenderFromArg(Arg: unknown, Override: Function): number { - if (typeof Arg !== 'object' || Arg === null) return 1 - if (typeof OriginalReflectApply(OriginalObjectGetOwnPropertyDescriptor, BrowserWindow.Object, [Arg, '_'])?.get === 'function') return null - - const Visited = new Set() - let Current: unknown = (Arg as Record)['_'] - - while (typeof Current === 'object' && Current !== null) { - if (Visited.has(Current)) return 2 - Visited.add(Current) - - const RecordCurrent = Current as Record - const Render = RecordCurrent['render'] - - if (typeof Render === 'function') { - RecordCurrent['render'] = Override - return 0 - } - - Current = RecordCurrent['parent'] - } - - return 3 - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - function PowerLinkVnodeTypeRenderFromArg(Arg: unknown): Function | null { - if (typeof Arg !== 'object' || Arg === null) return null - if (typeof OriginalReflectApply(OriginalObjectGetOwnPropertyDescriptor, BrowserWindow.Object, [Arg, '_'])?.get === 'function') return null - - const Visited = new Set() - let Current: unknown = (Arg as Record)['_'] - - while (typeof Current === 'object' && Current !== null) { - if (Visited.has(Current)) break - Visited.add(Current) - - const CurrentRecord = Current as Record - const Render = CurrentRecord['render'] - if (typeof Render === 'function') return Render - - const VNode = CurrentRecord['vnode'] - if (typeof VNode === 'object' && VNode !== null) { - const Type = (VNode as Record)['type'] - if (typeof Type === 'object' && Type !== null) { - const TypeRender = (Type as Record)['render'] - if (typeof TypeRender === 'function') return TypeRender - } - } - - Current = CurrentRecord['parent'] - } - - return null - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - function PowerLinkOverrideVnodeTypeRenderOnlyFromArg(Arg: unknown, Override: Function): number { - if (typeof Arg !== 'object' || Arg === null) return 1 - if (typeof OriginalReflectApply(OriginalObjectGetOwnPropertyDescriptor, BrowserWindow.Object, [Arg, '_'])?.get === 'function') return null - - const Visited = new Set() - let Current: unknown = (Arg as Record)['_'] - - while (typeof Current === 'object' && Current !== null) { - if (Visited.has(Current)) return 2 - Visited.add(Current) - - const CurrentRecord = Current as Record - const VNode = CurrentRecord['vnode'] - - if (typeof VNode === 'object' && VNode !== null) { - const Type = (VNode as Record)['type'] - - if (typeof Type === 'object' && Type !== null) { - const TypeRender = (Type as Record)['render'] + const ArticleHTMLElement = await WaitForElement('#app', BrowserWindow.document) + AttachVueSettledEvents(ArticleHTMLElement, { + QuietMs: 250, + EventName: 'vue:settled', + ChangeEventName: 'vue:change' + }) - if (typeof TypeRender === 'function') { - (Type as Record)['render'] = Override - return 0 + const OCRInstance = CreateOcrWorkerClient(BrowserWindow, new Worker(URL.createObjectURL(new Blob([__OCR_WORKER_CODE__], { type: 'application/javascript' })))) + + ArticleHTMLElement.addEventListener('vue:settled', async () => { + let Targeted = [...document.querySelectorAll('#app div[class] div[class] ~ div[class]')].filter(Ele => Ele instanceof HTMLElement) + Targeted = Targeted.filter(Ele => parseFloat(getComputedStyle(Ele).getPropertyValue('padding-top')) >= 20 || parseFloat(getComputedStyle(Ele).getPropertyValue('margin-top')) >= 20) + Targeted = Targeted.filter(Ele => [...Ele.querySelectorAll('*')].filter(Child => + parseFloat(getComputedStyle(Child).getPropertyValue('padding-top')) >= 5 && parseFloat(getComputedStyle(Child).getPropertyValue('border-bottom-width')) >= 0.1 + ).length === 1) + Targeted = await (async () => { + const NextTargeted = [] + for (const Parent of Targeted) { + const CandidateChildren = [...Parent.querySelectorAll('*')] + .filter(Child => Child instanceof HTMLElement) + .filter(Child => + Child instanceof HTMLImageElement || + getComputedStyle(Child).backgroundImage !== 'none' + ) + let MatchedCount = 0 + for (const Child of CandidateChildren) { + const Result = await OCRInstance.DetectFromElement(Child, { + ScoreThreshold: 0.32 + }) + if (Result !== null) { + MatchedCount += 1 } - } - } - - Current = CurrentRecord['parent'] - } - - return 3 - } - - function MatchPL2TrackingString(Value: string): boolean { - const HotkeyArray: { - Key: 'StartWith' | 'Includes' | 'EndsWith', - String: string - }[] = [{ - Key: 'Includes', - String: '//ader.naver.com/' - }, { - Key: 'Includes', - String: '//ader.naver.com/' - }, { - Key: 'StartWith', - String: '!/jump/' - }, { - Key: 'StartWith', - String: '//i.namu.wiki/i/' - }] - return HotkeyArray.some(Hotkey => { - switch (Hotkey.Key) { - case 'StartWith': - return Value.startsWith(Hotkey.String) - case 'Includes': - return Value.includes(Hotkey.String) - case 'EndsWith': - return Value.endsWith(Hotkey.String) - } - }) - } - - function ProxySetHandlerNewValueCheck(NewValue: Parameters['set']>[2]): boolean { - let Stringified: string = String(NewValue) - return MatchPL2TrackingString(Stringified) - } - - function ProxySetHandlerTargetCheck( - Target: object, - Visited = new WeakSet() - ): boolean { - if (Visited.has(Target)) { - return false - } - Visited.add(Target) - - for (const PropertyName of Object.keys(Target)) { - const Value = (Target as Record)[PropertyName] - const Descriptor = OriginalObjectGetOwnPropertyDescriptor(Target, PropertyName) - - if ( - typeof Value === 'object' && - Value !== null && - typeof Descriptor?.get !== 'function' - ) { - if (ProxySetHandlerTargetCheck(Value, Visited)) { - return true - } - } else if ( - typeof Value === 'string' && - MatchPL2TrackingString(Value) - ) { - return true - } - } - - return false - } - - function MatchesShape(Schema: unknown, Target: unknown): boolean { - if (Schema === null || Target === null) { - return Schema === Target - } - - if (Array.isArray(Schema)) { - if (!Array.isArray(Target)) return false - if (Schema.length !== Target.length) return false - - return Schema.every((SchemaItem, Index) => - MatchesShape(SchemaItem, Target[Index]) - ) - } - - if (typeof Schema === 'object') { - if (typeof Target !== 'object' || Array.isArray(Target)) return false - if (Target === null) return false - - const SchemaValues = Object.values(Schema as Record) - const TargetValues = Object.values(Target as Record) - - const Used = new Array(TargetValues.length).fill(false) - - for (const SchemaValue of SchemaValues) { - let Found = false - - for (let I = 0; I < TargetValues.length; I++) { - if (!Used[I] && MatchesShape(SchemaValue, TargetValues[I])) { - Used[I] = true - Found = true + if (MatchedCount >= 1) { + NextTargeted.push(Parent) break } } - - if (!Found) return false } - - return true - } - - return typeof Schema === typeof Target - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - let VuejsPL2Render: Set = new Set() - BrowserWindow.Proxy = new Proxy(OriginalProxy, { - construct(Target: typeof Proxy, Args: ConstructorParameters) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - let VuejsRender: Function | null = PowerLinkRenderFromArg(Args[0]) - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - let VuejsCtxSubtreeRender: Function | null = PowerLinkVnodeTypeRenderFromArg(Args[0]) - let PL2Element: HTMLElement | null = PowerLinkElementFromArgParent(Args[0]) - let Stringified = String(VuejsRender ?? '') - if (VuejsRender !== null && PL2Element !== null && Stringified.length < 500 && - PL2MajorFuncCallPatterns.filter(Patterns => Patterns.filter(Pattern => Pattern.test(Stringified)).length === Patterns.length).length === 1 && - [...PL2Element.querySelectorAll('*')].filter(Child => { - if (!(Child instanceof HTMLElement)) return false - let PL2TitleHeight = Child.getClientRects()[0]?.height ?? 0 - let PL2TitleMarginBottom = Math.max(ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')), - ParseCssFloat(getComputedStyle(Child).getPropertyValue('margin-bottom'))) - return PL2TitleHeight > 0 && PL2TitleMarginBottom >= PL2TitleHeight * (MinRatio - EpsilonRatio) && PL2TitleMarginBottom <= PL2TitleHeight * (MaxRatio + EpsilonRatio) - }).length >= 1 - ) { - console.debug(`[${UserscriptName}]: Prevented declaring render function in Vue.js 3 for detected PowerLink skeleton:`, Args[0], PL2Element) - VuejsPL2Render.add(VuejsRender) - PowerLinkOverrideRenderFromArg(Args[0], () => null) - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolderProxy')) - } else if (VuejsCtxSubtreeRender !== null && VuejsPL2Render.has(VuejsCtxSubtreeRender)) { - console.debug(`[${UserscriptName}]: Prevented declaring render function in Vue.js 3 in SPA moving for detected PowerLink skeleton:`, Args[0]) - PowerLinkOverrideVnodeTypeRenderOnlyFromArg(Args[0], () => null) - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolderProxy')) - return Reflect.construct(Target, Args) - } - if (typeof Args[1].set === 'function') { - const OriginalSet = Args[1].set - Args[1].set = function(...SetArgs: Parameters) { - if (ProxySetHandlerNewValueCheck(SetArgs[2])) { - console.debug(`[${UserscriptName}]: Proxy set called for PowerLink Skeleton:`, SetArgs) - return - } - if (ProxySetHandlerTargetCheck(SetArgs[0]) && MatchesShape({ - Dummy: [], - Dummy2: [], - LayoutFormat: '', - NumberKey: [0, 0, 0], - PowerLinkText: [ - { - Url: '', - Title: '', - No: 0, - VSkip: true - } - ] - }, SetArgs[0])) { - console.debug(`[${UserscriptName}]: Proxy set called for PowerLink Skeleton (target check):`, SetArgs) - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolderProxy')) - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolderMobile')) - BrowserWindow.document.dispatchEvent(new CustomEvent('PL2PlaceHolder')) - return - } - return OriginalReflectApply(OriginalSet, this, SetArgs) - } - } - return Reflect.construct(Target, Args) - } - }) - - BrowserWindow.document.addEventListener('PL2AdvertContainer', () => { - setTimeout(() => { - let ContainerElements = new Set([CommentContainer]) - ContainerElements = new Set([...ContainerElements, ...[...ContainerElements].flatMap(Container => [...Container.querySelectorAll('*')])]) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-bottom-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-left-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-right-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-top-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => [...Container.querySelectorAll('*')].some(Child => { - if (!(Child instanceof HTMLElement)) return false - let PL2TitleHeight = Child.getClientRects()[0]?.height ?? 0 - let PL2TitleMarginBottom = ParseCssFloat(getComputedStyle(Child).getPropertyValue('margin-bottom')) - if (PL2TitleHeight === 0) return false - return PL2TitleMarginBottom >= PL2TitleHeight * 0.65 && PL2TitleMarginBottom <= PL2TitleHeight * 1.25 - }))) - console.debug(`[${UserscriptName}]: Removing PowerLink Skeleton Containers (PL2AdvertContainer):`, ContainerElements) - ContainerElements.forEach(Container => { - Container.setAttribute('style', 'display: none !important;') - }) - }, 2500) - }) - - BrowserWindow.document.addEventListener('PL2PlaceHolderMobile', () => { - setTimeout(() => { - let ContainerElements = new Set([...BrowserWindow.document.querySelectorAll('div[class] div[class] div[class] ~ div[class]')]) - ContainerElements = new Set([...ContainerElements].filter(Container => Container instanceof HTMLElement)) - ContainerElements = new Set([...ContainerElements].filter(Container => - ParseCssFloat(getComputedStyle(Container).getPropertyValue('margin-bottom')) > 15 || - ParseCssFloat(getComputedStyle(Container).getPropertyValue('padding-top')) > 20 - )) - ContainerElements = new Set([...ContainerElements].filter(Container => Container instanceof HTMLElement && Container.innerText.trim().length === 0)) - ContainerElements = new Set([...ContainerElements].filter(Container => [...Container.querySelectorAll('*')].some(Child => Child instanceof HTMLElement && - (ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-top')) >= 5 && - ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-bottom')) >= 5 && - ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-left')) >= 5 && - ParseCssFloat(getComputedStyle(Child).getPropertyValue('padding-right')) >= 5) - ))) - console.debug(`[${UserscriptName}]: Removing PowerLink Skeleton Containers (PL2PlaceHolderMobile):`, ContainerElements) - ContainerElements.forEach(Container => { - Container.setAttribute('style', 'display: none !important;') - }) - }, 2500) - }) - - BrowserWindow.document.addEventListener('PL2PlaceHolder', () => { - setTimeout(() => { - let ContainerElements = new Set([...BrowserWindow.document.querySelectorAll('div[class] div[class] div[class] ~ div[class]')]) - ContainerElements = new Set([...ContainerElements].filter(Container => Container instanceof HTMLElement)) - ContainerElements = new Set([...ContainerElements].filter(Container => { - return ParseCssFloat(getComputedStyle(Container).getPropertyValue('padding-top')) > 10 || - ParseCssFloat(getComputedStyle(Container).getPropertyValue('margin-top')) > 10 - })) - ContainerElements = new Set([...ContainerElements, ...[...ContainerElements].flatMap(Container => [...Container.querySelectorAll('*:not(button)')])]) - ContainerElements = new Set([...ContainerElements].filter(Container => Container instanceof HTMLElement && Container.innerText.trim().length === 0)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-bottom-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-left-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-right-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-top-width')) >= 0.5)) - ContainerElements = new Set([...ContainerElements].filter(Container => ParseCssFloat(getComputedStyle(Container).getPropertyValue('transition-duration')) >= 0.01)) - console.debug(`[${UserscriptName}]: Removing PowerLink Skeleton Containers (PL2PlaceHolder):`, ContainerElements) - ContainerElements.forEach(Container => { - Container.setAttribute('style', 'display: none !important;') - }) - }, 2500) + return NextTargeted + })() + Targeted.forEach(Ele => Targeted.push(...new Set([...Ele.querySelectorAll('*')].filter(Child => Child instanceof HTMLElement)))) + Targeted = [...new Set(Targeted)] + Targeted = Targeted.filter(Ele => parseFloat(getComputedStyle(Ele).getPropertyValue('padding-left')) >= 5 && parseFloat(getComputedStyle(Ele).getPropertyValue('border-right-width')) >= 0.1) + console.debug(`[${UserscriptName}] Detected ${Targeted.length} potential ad elements.`, Targeted) + Targeted.forEach(Ele => { + Ele.style.setProperty('display', 'none', 'important') + }) }) - BrowserWindow.document.addEventListener('PL2PlaceHolderProxy', () => { - setTimeout(() => { - let ContainerElements = new Set([...BrowserWindow.document.querySelectorAll('div[class] div[class] div[class] ~ div[class]')]) - ContainerElements = new Set([...ContainerElements].filter(Container => Container instanceof HTMLElement)) - ContainerElements = new Set([...ContainerElements].filter(Container => { - return ParseCssFloat(getComputedStyle(Container).getPropertyValue('padding-top')) >= 5 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('padding-bottom')) >= 5 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('padding-left')) >= 5 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('padding-right')) >= 5 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-top-width')) >= 0.35 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-bottom-width')) >= 0.35 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-left-width')) >= 0.35 && - ParseCssFloat(getComputedStyle(Container).getPropertyValue('border-right-width')) >= 0.35 && - Container.getClientRects()[0]?.height <= 20 && Container.getClientRects()[0]?.height > 0 - })) - ContainerElements = new Set([...ContainerElements].filter(Container => Container instanceof HTMLElement && Container.innerText.trim().length === 0)) - console.debug(`[${UserscriptName}]: Removing PowerLink Skeleton Containers (PL2PlaceHolderProxy):`, ContainerElements) - ContainerElements.forEach(Container => { - Container.setAttribute('style', 'display: none !important;') - }) - }, 2500) + // init Naver Nanum fonts + const FontAddr = [ + 'https://fonts.googleapis.com/css2?family=Nanum Gothic&display=swap', + ] + FontAddr.forEach(Addr => { + const Link = BrowserWindow.document.createElement('link') + Link.rel = 'stylesheet' + Link.href = Addr + BrowserWindow.document.head.appendChild(Link) }) } -RunNamuLinkUserscript(Win) \ No newline at end of file +void RunNamuLinkUserscript(Win) \ No newline at end of file diff --git a/userscript/source/ocr-client.ts b/userscript/source/ocr-client.ts new file mode 100644 index 0000000..4d65605 --- /dev/null +++ b/userscript/source/ocr-client.ts @@ -0,0 +1,374 @@ +import type { MatchResult, WorkerDetectRequest, WorkerResponse } from './ocr-types.js' + +type DetectElementOptions = { + FontCandidates?: readonly string[] + ScoreThreshold?: number +} + +type DetectSourceOptions = DetectElementOptions & { + HostElement: HTMLElement + SourceUrl: string +} + +function ParseBackgroundImageUrl(BackgroundImage: string): string | null { + const Trimmed = BackgroundImage.trim() + if (!Trimmed || Trimmed === 'none') return null + + const Match = Trimmed.match(/^url\((.*)\)$/i) + if (!Match) return null + + let Inner = Match[1].trim() + if ((Inner.startsWith('"') && Inner.endsWith('"')) || (Inner.startsWith('\'') && Inner.endsWith('\''))) { + Inner = Inner.slice(1, -1) + } + return Inner +} + +function GetElementEffectiveBackgroundColor(BrowserWindow: typeof window, Element: HTMLElement): string { + let Node: HTMLElement | null = Element + + while (Node) { + const Background = BrowserWindow.getComputedStyle(Node).backgroundColor + if (Background && Background !== 'transparent' && Background !== 'rgba(0, 0, 0, 0)') { + return Background + } + Node = Node.parentElement + } + + return 'rgb(255, 255, 255)' +} + +function GetBackgroundColorCandidates(BrowserWindow: typeof window, Element: HTMLElement, FallbackBackground: string): string[] { + const Candidates = new Set() + + function AddCandidate(BackgroundColor: string | null | undefined): void { + if (!BackgroundColor) return + if (BackgroundColor === 'transparent' || BackgroundColor === 'rgba(0, 0, 0, 0)') return + Candidates.add(BackgroundColor) + } + + AddCandidate(FallbackBackground) + + const ElementStyle = BrowserWindow.getComputedStyle(Element) + AddCandidate(ElementStyle.backgroundColor) + AddCandidate(ElementStyle.color) + AddCandidate(BrowserWindow.getComputedStyle(BrowserWindow.document.documentElement).backgroundColor) + + if (BrowserWindow.document.body) { + const BodyStyle = BrowserWindow.getComputedStyle(BrowserWindow.document.body) + AddCandidate(BodyStyle.backgroundColor) + AddCandidate(BodyStyle.color) + } + + AddCandidate('rgb(255, 255, 255)') + AddCandidate('rgb(0, 0, 0)') + + return [...Candidates] +} + +function ResolveElementSource(BrowserWindow: typeof window, Element: HTMLElement): string | null { + if (Element instanceof BrowserWindow.HTMLImageElement) { + const ImageSource = Element.currentSrc || Element.src || '' + return ImageSource || null + } + + const BackgroundImage = BrowserWindow.getComputedStyle(Element).backgroundImage + return ParseBackgroundImageUrl(BackgroundImage) +} + +export function CreateOcrWorkerClient(BrowserWindow: typeof window, WorkerInstance: Worker) { + let RequestSequence = 0 + const Pending = new Map void, Reject: (Reason?: unknown) => void }>() + + WorkerInstance.addEventListener('message', (Event: MessageEvent) => { + const Message = Event.data + if (!Message || !('RequestId' in Message)) return + + const PendingRequest = Pending.get(Message.RequestId) + if (!PendingRequest) return + Pending.delete(Message.RequestId) + + if (Message.Kind === 'detect-result') { + PendingRequest.Resolve(Message.Result) + return + } + + PendingRequest.Reject(new Error(Message.Error)) + }) + + function PostDetect(Request: Omit): Promise { + const RequestId = `ocr-${Date.now()}-${RequestSequence++}` + const Message: WorkerDetectRequest = { + Kind: 'detect', + RequestId, + ...Request, + } + + return new Promise((Resolve, Reject) => { + Pending.set(RequestId, { Resolve, Reject }) + WorkerInstance.postMessage(Message) + }) + } + + async function DetectFromElement(Element: HTMLElement, Options?: DetectElementOptions): Promise { + const SourceUrl = ResolveElementSource(BrowserWindow, Element) + if (!SourceUrl) return null + + const ImageDataValue = await LoadImageDataFromSourceUrl(BrowserWindow, Element, SourceUrl) + const FallbackBackground = GetElementEffectiveBackgroundColor(BrowserWindow, Element) + const BackgroundCandidates = GetBackgroundColorCandidates(BrowserWindow, Element, FallbackBackground) + + return PostDetect({ + ImageData: ImageDataValue, + BackgroundCandidates, + FontCandidates: Options?.FontCandidates, + ScoreThreshold: Options?.ScoreThreshold, + }) + } + + async function DetectFromSource(Options: DetectSourceOptions): Promise { + const ImageDataValue = await LoadImageDataFromSourceUrl( + BrowserWindow, + Options.HostElement, + Options.SourceUrl, + ) + + const FallbackBackground = GetElementEffectiveBackgroundColor(BrowserWindow, Options.HostElement) + const BackgroundCandidates = GetBackgroundColorCandidates(BrowserWindow, Options.HostElement, FallbackBackground) + + return PostDetect({ + ImageData: ImageDataValue, + BackgroundCandidates, + FontCandidates: Options.FontCandidates, + ScoreThreshold: Options.ScoreThreshold, + }) + } + + return { + DetectFromElement, + DetectFromSource, + Terminate(): void { + for (const PendingRequest of Pending.values()) { + PendingRequest.Reject(new Error('OCR worker terminated')) + } + Pending.clear() + WorkerInstance.terminate() + }, + } +} + +function IsSvgDataUrl(SourceUrl: string): boolean { + return /^data:image\/svg\+xml(?:[;,]|$)/i.test(SourceUrl) +} + +function DecodeBase64Utf8(BrowserWindow: typeof window, Base64Text: string): string { + const Binary = BrowserWindow.atob(Base64Text) + const Bytes = new Uint8Array(Binary.length) + + for (let Index = 0; Index < Binary.length; Index++) { + Bytes[Index] = Binary.charCodeAt(Index) + } + + return new TextDecoder().decode(Bytes) +} + +function DecodeSvgDataUrl(BrowserWindow: typeof window, SourceUrl: string): string { + const CommaIndex = SourceUrl.indexOf(',') + if (CommaIndex < 0) throw new Error('Invalid SVG data URL') + + const Header = SourceUrl.slice(0, CommaIndex).toLowerCase() + const Payload = SourceUrl.slice(CommaIndex + 1) + + if (Header.includes(';base64')) { + return DecodeBase64Utf8(BrowserWindow, Payload) + } + + return decodeURIComponent(Payload) +} + +function PrepareSvgMarkupForRasterize( + BrowserWindow: typeof window, + SvgMarkup: string, + Width: number, + Height: number, +): string { + const Parser = new BrowserWindow.DOMParser() + const XmlDocument = Parser.parseFromString(SvgMarkup, 'image/svg+xml') + + if (XmlDocument.querySelector('parsererror')) { + throw new Error('Failed to parse SVG markup') + } + + const SvgElement = XmlDocument.documentElement + if (!SvgElement || SvgElement.nodeName.toLowerCase() !== 'svg') { + throw new Error('SVG root element not found') + } + + if (!SvgElement.getAttribute('xmlns')) { + SvgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + } + + if (!SvgElement.getAttribute('width')) { + SvgElement.setAttribute('width', String(Width)) + } + + if (!SvgElement.getAttribute('height')) { + SvgElement.setAttribute('height', String(Height)) + } + + return new BrowserWindow.XMLSerializer().serializeToString(XmlDocument) +} + +function WaitForImageLoad(ImageElement: HTMLImageElement): Promise { + if (ImageElement.complete && ImageElement.naturalWidth > 0) { + return Promise.resolve() + } + + return new Promise((Resolve, Reject) => { + function Cleanup(): void { + ImageElement.removeEventListener('load', OnLoad) + ImageElement.removeEventListener('error', OnError) + } + + function OnLoad(): void { + Cleanup() + Resolve() + } + + function OnError(): void { + Cleanup() + Reject(new Error('Failed to load SVG image')) + } + + ImageElement.addEventListener('load', OnLoad) + ImageElement.addEventListener('error', OnError) + }) +} + +async function LoadImageElement(BrowserWindow: typeof window, SourceUrl: string): Promise { + const ImageElement = new BrowserWindow.Image() + ImageElement.decoding = 'async' + ImageElement.src = SourceUrl + + try { + await ImageElement.decode() + if (ImageElement.naturalWidth > 0) { + return ImageElement + } + } catch { + } + + await WaitForImageLoad(ImageElement) + return ImageElement +} + +function IsSvgMimeType(MimeType: string | null): boolean { + return typeof MimeType === 'string' && /^image\/svg\+xml(?:\s*;|$)/i.test(MimeType) +} + +function GetRasterSize(BrowserWindow: typeof window, HostElement: HTMLElement): { Width: number, Height: number } { + const Rect = HostElement.getBoundingClientRect() + const Scale = Math.max(1, BrowserWindow.devicePixelRatio || 1) + + return { + Width: Math.max(1, Math.round((Rect.width || HostElement.clientWidth || 96) * Scale)), + Height: Math.max(1, Math.round((Rect.height || HostElement.clientHeight || 32) * Scale)), + } +} + +async function RasterizeSvgMarkupToImageData( + BrowserWindow: typeof window, + HostElement: HTMLElement, + SvgMarkup: string, +): Promise { + const { Width, Height } = GetRasterSize(BrowserWindow, HostElement) + const PreparedSvgMarkup = PrepareSvgMarkupForRasterize(BrowserWindow, SvgMarkup, Width, Height) + + const SvgBlobUrl = BrowserWindow.URL.createObjectURL( + new BrowserWindow.Blob([PreparedSvgMarkup], { type: 'image/svg+xml' }) + ) + + try { + const ImageElement = await LoadImageElement(BrowserWindow, SvgBlobUrl) + + const Canvas = BrowserWindow.document.createElement('canvas') + Canvas.width = Width + Canvas.height = Height + + const Context2D = Canvas.getContext('2d', { willReadFrequently: true }) + if (!Context2D) throw new Error('2D context unavailable') + + Context2D.clearRect(0, 0, Width, Height) + Context2D.drawImage(ImageElement, 0, 0, Width, Height) + + return Context2D.getImageData(0, 0, Width, Height) + } finally { + BrowserWindow.URL.revokeObjectURL(SvgBlobUrl) + } +} + +async function RasterizeBitmapBlobToImageData( + BrowserWindow: typeof window, + BlobData: Blob, +): Promise { + const Bitmap = await BrowserWindow.createImageBitmap(BlobData) + + try { + const Canvas = BrowserWindow.document.createElement('canvas') + Canvas.width = Bitmap.width + Canvas.height = Bitmap.height + + const Context2D = Canvas.getContext('2d', { willReadFrequently: true }) + if (!Context2D) throw new Error('2D context unavailable') + + Context2D.drawImage(Bitmap, 0, 0) + return Context2D.getImageData(0, 0, Canvas.width, Canvas.height) + } finally { + Bitmap.close() + } +} + +async function LoadImageDataFromSourceUrl( + BrowserWindow: typeof window, + HostElement: HTMLElement, + SourceUrl: string, +): Promise { + if (IsSvgDataUrl(SourceUrl)) { + const SvgMarkup = DecodeSvgDataUrl(BrowserWindow, SourceUrl) + return await RasterizeSvgMarkupToImageData(BrowserWindow, HostElement, SvgMarkup) + } + + const ResponseData = await new Promise<{ BlobData: Blob, ContentTypeHeader: string | null }>((Resolve, Reject) => { + GM.xmlHttpRequest({ + url: SourceUrl, + method: 'GET', + responseType: 'blob', + onload: (ResponseValue) => { + if (ResponseValue.status < 200 || ResponseValue.status >= 300) { + Reject(new Error(`Failed to fetch image: ${ResponseValue.status} ${ResponseValue.statusText}`)) + return + } + + const BlobData = ResponseValue.response + if (!(BlobData instanceof Blob)) { + Reject(new Error('Failed to fetch image: invalid blob response')) + return + } + + const HeaderMatch = ResponseValue.responseHeaders.match(/^content-type:\s*(.+)$/im) + const ContentTypeHeader = HeaderMatch ? HeaderMatch[1].trim() : null + + Resolve({ BlobData, ContentTypeHeader }) + }, + onerror: Reject, + ontimeout: Reject, + }) + }) + + if (IsSvgMimeType(ResponseData.BlobData.type) || IsSvgMimeType(ResponseData.ContentTypeHeader)) { + const SvgMarkup = await ResponseData.BlobData.text() + return await RasterizeSvgMarkupToImageData(BrowserWindow, HostElement, SvgMarkup) + } + + return await RasterizeBitmapBlobToImageData(BrowserWindow, ResponseData.BlobData) +} \ No newline at end of file diff --git a/userscript/source/ocr-types.ts b/userscript/source/ocr-types.ts new file mode 100644 index 0000000..0bf8c9f --- /dev/null +++ b/userscript/source/ocr-types.ts @@ -0,0 +1,40 @@ +export type TargetLabel = '파워링크' | '광고' | '광고등록' + +export type BoundingBox = { + X: number + Y: number + Width: number + Height: number +} + +export type MatchResult = + | { + Label: TargetLabel + Score: number + Box: BoundingBox + } + | null + +export type WorkerDetectRequest = { + Kind: 'detect' + RequestId: string + ImageData: ImageData + BackgroundCandidates: string[] + FontCandidates?: readonly string[] + ScoreThreshold?: number +} + +export type WorkerDetectSuccessResponse = { + Kind: 'detect-result' + RequestId: string + Result: MatchResult +} + +export type WorkerDetectErrorResponse = { + Kind: 'detect-error' + RequestId: string + Error: string +} + +export type WorkerMessage = WorkerDetectRequest +export type WorkerResponse = WorkerDetectSuccessResponse | WorkerDetectErrorResponse diff --git a/userscript/source/ocr-worker.ts b/userscript/source/ocr-worker.ts new file mode 100644 index 0000000..cde2fda --- /dev/null +++ b/userscript/source/ocr-worker.ts @@ -0,0 +1,514 @@ +import type { + BoundingBox, + MatchResult, + TargetLabel, + WorkerDetectRequest, + WorkerDetectSuccessResponse, + WorkerDetectErrorResponse, +} from './ocr-types.js' + +type GrayImage = { + Width: number + Height: number + Data: Uint8ClampedArray +} + +type BinaryImage = { + Width: number + Height: number + Data: Uint8Array +} + +const Targets: readonly TargetLabel[] = ['파워링크', '광고', '광고등록'] as const +const DefaultFontCandidates = [ + 'Pretendard JP, sans-serif', + 'Pretendard, sans-serif', + 'system-ui, sans-serif', + 'Apple SD Gothic Neo, sans-serif', + 'Nanum Gothic, sans-serif', + 'Noto Sans KR, sans-serif', + 'Arial, sans-serif', +] as const + +const TemplateCache = new Map() + +function CreateCanvas(Width: number, Height: number): OffscreenCanvas { + return new OffscreenCanvas(Math.max(1, Math.floor(Width)), Math.max(1, Math.floor(Height))) +} + +function Get2DContext(Canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D { + const Context2D = Canvas.getContext('2d', { willReadFrequently: true }) + if (!Context2D) throw new Error('2D context unavailable') + return Context2D +} + + +// function DrawImageWithBackground( +// Source: CanvasImageSource, +// Width: number, +// Height: number, +// BackgroundCssColor: string, +// ): OffscreenCanvas { +// const Canvas = CreateCanvas(Width, Height) +// const Context2D = Get2DContext(Canvas) +// Context2D.fillStyle = BackgroundCssColor +// Context2D.fillRect(0, 0, Width, Height) +// Context2D.drawImage(Source, 0, 0, Width, Height) +// return Canvas +// } + +function CanvasToGrayImage(Canvas: OffscreenCanvas): GrayImage { + const Context2D = Get2DContext(Canvas) + const { width: Width, height: Height } = Canvas + const Rgba = Context2D.getImageData(0, 0, Width, Height).data + const Gray = new Uint8ClampedArray(Width * Height) + + for (let Index = 0, Pixel = 0; Index < Rgba.length; Index += 4, Pixel++) { + const Red = Rgba[Index] + const Green = Rgba[Index + 1] + const Blue = Rgba[Index + 2] + Gray[Pixel] = Math.round(0.299 * Red + 0.587 * Green + 0.114 * Blue) + } + + return { Width, Height, Data: Gray } +} + +function OtsuThreshold(Gray: GrayImage): number { + const Histogram = new Uint32Array(256) + for (let Index = 0; Index < Gray.Data.length; Index++) Histogram[Gray.Data[Index]]++ + + const Total = Gray.Data.length + let Sum = 0 + for (let Index = 0; Index < 256; Index++) Sum += Index * Histogram[Index] + + let SumBackground = 0 + let WeightBackground = 0 + let MaxVariance = -1 + let Threshold = 127 + + for (let ThresholdIndex = 0; ThresholdIndex < 256; ThresholdIndex++) { + WeightBackground += Histogram[ThresholdIndex] + if (WeightBackground === 0) continue + + const WeightForeground = Total - WeightBackground + if (WeightForeground === 0) break + + SumBackground += ThresholdIndex * Histogram[ThresholdIndex] + const MeanBackground = SumBackground / WeightBackground + const MeanForeground = (Sum - SumBackground) / WeightForeground + const BetweenClassVariance = + WeightBackground * WeightForeground * (MeanBackground - MeanForeground) * (MeanBackground - MeanForeground) + + if (BetweenClassVariance > MaxVariance) { + MaxVariance = BetweenClassVariance + Threshold = ThresholdIndex + } + } + + return Threshold +} + +function BinarizeByContrast(Gray: GrayImage): BinaryImage { + const Threshold = OtsuThreshold(Gray) + let DarkCount = 0 + let LightCount = 0 + + for (let Index = 0; Index < Gray.Data.length; Index++) { + if (Gray.Data[Index] < Threshold) DarkCount++ + else LightCount++ + } + + const TextIsDark = DarkCount < LightCount + const Output = new Uint8Array(Gray.Width * Gray.Height) + + for (let Index = 0; Index < Gray.Data.length; Index++) { + const IsText = TextIsDark ? Gray.Data[Index] < Threshold : Gray.Data[Index] > Threshold + Output[Index] = IsText ? 1 : 0 + } + + return { Width: Gray.Width, Height: Gray.Height, Data: Output } +} + +function Erode3x3(Source: BinaryImage): BinaryImage { + const Output = new Uint8Array(Source.Width * Source.Height) + + for (let Y = 1; Y < Source.Height - 1; Y++) { + for (let X = 1; X < Source.Width - 1; X++) { + let Keep = 1 + for (let DeltaY = -1; DeltaY <= 1 && Keep; DeltaY++) { + for (let DeltaX = -1; DeltaX <= 1; DeltaX++) { + if (Source.Data[(Y + DeltaY) * Source.Width + (X + DeltaX)] === 0) { + Keep = 0 + break + } + } + } + Output[Y * Source.Width + X] = Keep + } + } + + return { Width: Source.Width, Height: Source.Height, Data: Output } +} + +function Dilate3x3(Source: BinaryImage): BinaryImage { + const Output = new Uint8Array(Source.Width * Source.Height) + + for (let Y = 1; Y < Source.Height - 1; Y++) { + for (let X = 1; X < Source.Width - 1; X++) { + let Value = 0 + for (let DeltaY = -1; DeltaY <= 1 && !Value; DeltaY++) { + for (let DeltaX = -1; DeltaX <= 1; DeltaX++) { + if (Source.Data[(Y + DeltaY) * Source.Width + (X + DeltaX)] === 1) { + Value = 1 + break + } + } + } + Output[Y * Source.Width + X] = Value + } + } + + return { Width: Source.Width, Height: Source.Height, Data: Output } +} + +function OpenClose(Source: BinaryImage): BinaryImage { + return Dilate3x3(Erode3x3(Dilate3x3(Source))) +} + +function FindConnectedComponents(Source: BinaryImage, MinArea = 20): BoundingBox[] { + const Visited = new Uint8Array(Source.Width * Source.Height) + const Boxes: BoundingBox[] = [] + const QueueX = new Int32Array(Source.Width * Source.Height) + const QueueY = new Int32Array(Source.Width * Source.Height) + + for (let Y = 0; Y < Source.Height; Y++) { + for (let X = 0; X < Source.Width; X++) { + const Index = Y * Source.Width + X + if (Visited[Index] || Source.Data[Index] === 0) continue + + let Head = 0 + let Tail = 0 + QueueX[Tail] = X + QueueY[Tail] = Y + Tail++ + Visited[Index] = 1 + + let MinX = X + let MinY = Y + let MaxX = X + let MaxY = Y + let Area = 0 + + while (Head < Tail) { + const CurrentX = QueueX[Head] + const CurrentY = QueueY[Head] + Head++ + Area++ + if (CurrentX < MinX) MinX = CurrentX + if (CurrentY < MinY) MinY = CurrentY + if (CurrentX > MaxX) MaxX = CurrentX + if (CurrentY > MaxY) MaxY = CurrentY + + for (let DeltaY = -1; DeltaY <= 1; DeltaY++) { + for (let DeltaX = -1; DeltaX <= 1; DeltaX++) { + if (DeltaX === 0 && DeltaY === 0) continue + const NextX = CurrentX + DeltaX + const NextY = CurrentY + DeltaY + if (NextX < 0 || NextY < 0 || NextX >= Source.Width || NextY >= Source.Height) continue + + const NextIndex = NextY * Source.Width + NextX + if (Visited[NextIndex] || Source.Data[NextIndex] === 0) continue + Visited[NextIndex] = 1 + QueueX[Tail] = NextX + QueueY[Tail] = NextY + Tail++ + } + } + } + + if (Area >= MinArea) { + Boxes.push({ X: MinX, Y: MinY, Width: MaxX - MinX + 1, Height: MaxY - MinY + 1 }) + } + } + } + + return Boxes +} + +function MergeNearbyBoxes(Boxes: BoundingBox[], GapX = 8, GapY = 4): BoundingBox[] { + const Result = [...Boxes] + let Changed = true + + function OverlapsOrNear(A: BoundingBox, B: BoundingBox): boolean { + const AX2 = A.X + A.Width + const AY2 = A.Y + A.Height + const BX2 = B.X + B.Width + const BY2 = B.Y + B.Height + return !( + AX2 + GapX < B.X + || BX2 + GapX < A.X + || AY2 + GapY < B.Y + || BY2 + GapY < A.Y + ) + } + + while (Changed) { + Changed = false + outer: for (let IndexA = 0; IndexA < Result.length; IndexA++) { + for (let IndexB = IndexA + 1; IndexB < Result.length; IndexB++) { + if (!OverlapsOrNear(Result[IndexA], Result[IndexB])) continue + const A = Result[IndexA] + const B = Result[IndexB] + Result[IndexA] = { + X: Math.min(A.X, B.X), + Y: Math.min(A.Y, B.Y), + Width: Math.max(A.X + A.Width, B.X + B.Width) - Math.min(A.X, B.X), + Height: Math.max(A.Y + A.Height, B.Y + B.Height) - Math.min(A.Y, B.Y), + } + Result.splice(IndexB, 1) + Changed = true + break outer + } + } + } + + return Result +} + +function CropBinary(Source: BinaryImage, Box: BoundingBox): BinaryImage { + const Output = new Uint8Array(Box.Width * Box.Height) + for (let Y = 0; Y < Box.Height; Y++) { + for (let X = 0; X < Box.Width; X++) { + Output[Y * Box.Width + X] = Source.Data[(Box.Y + Y) * Source.Width + (Box.X + X)] + } + } + return { Width: Box.Width, Height: Box.Height, Data: Output } +} + +function TrimBinary(Source: BinaryImage): BinaryImage { + let MinX = Source.Width + let MinY = Source.Height + let MaxX = -1 + let MaxY = -1 + + for (let Y = 0; Y < Source.Height; Y++) { + for (let X = 0; X < Source.Width; X++) { + if (Source.Data[Y * Source.Width + X] === 0) continue + if (X < MinX) MinX = X + if (Y < MinY) MinY = Y + if (X > MaxX) MaxX = X + if (Y > MaxY) MaxY = Y + } + } + + if (MaxX < MinX || MaxY < MinY) { + return { Width: 1, Height: 1, Data: new Uint8Array([0]) } + } + + return CropBinary(Source, { X: MinX, Y: MinY, Width: MaxX - MinX + 1, Height: MaxY - MinY + 1 }) +} + +function ResizeBinaryNearest(Source: BinaryImage, Width: number, Height: number): BinaryImage { + const Output = new Uint8Array(Width * Height) + + for (let Y = 0; Y < Height; Y++) { + for (let X = 0; X < Width; X++) { + const SourceX = Math.min(Source.Width - 1, Math.floor((X / Width) * Source.Width)) + const SourceY = Math.min(Source.Height - 1, Math.floor((Y / Height) * Source.Height)) + Output[Y * Width + X] = Source.Data[SourceY * Source.Width + SourceX] + } + } + + return { Width, Height, Data: Output } +} + +function NormalizeBinary(Source: BinaryImage, Size = 64): BinaryImage { + const Trimmed = TrimBinary(Source) + const Side = Math.max(Trimmed.Width, Trimmed.Height) + const Padded = new Uint8Array(Side * Side) + const OffsetX = Math.floor((Side - Trimmed.Width) / 2) + const OffsetY = Math.floor((Side - Trimmed.Height) / 2) + + for (let Y = 0; Y < Trimmed.Height; Y++) { + for (let X = 0; X < Trimmed.Width; X++) { + Padded[(Y + OffsetY) * Side + (X + OffsetX)] = Trimmed.Data[Y * Trimmed.Width + X] + } + } + + return ResizeBinaryNearest({ Width: Side, Height: Side, Data: Padded }, Size, Size) +} + +function XorDistance(Left: BinaryImage, Right: BinaryImage): number { + if (Left.Width !== Right.Width || Left.Height !== Right.Height) { + throw new Error('Image size mismatch') + } + let Different = 0 + for (let Index = 0; Index < Left.Data.length; Index++) { + if (Left.Data[Index] !== Right.Data[Index]) Different++ + } + return Different / Left.Data.length +} + +function GetTemplate(Text: string, FontFamily: string): BinaryImage { + const CacheKey = `${Text}__${FontFamily}` + const Cached = TemplateCache.get(CacheKey) + if (Cached) return Cached + + const Width = 256 + const Height = 96 + const Canvas = CreateCanvas(Width, Height) + const Context2D = Get2DContext(Canvas) + Context2D.fillStyle = 'white' + Context2D.fillRect(0, 0, Width, Height) + let FontSize = Math.floor(Height * 0.72) + + while (FontSize > 8) { + Context2D.clearRect(0, 0, Width, Height) + Context2D.fillStyle = 'white' + Context2D.fillRect(0, 0, Width, Height) + Context2D.fillStyle = 'black' + Context2D.textAlign = 'center' + Context2D.textBaseline = 'middle' + Context2D.font = `700 ${FontSize}px ${FontFamily}` + + const Metrics = Context2D.measureText(Text) + const TextWidth = Metrics.width + const TextHeight = + (Metrics.actualBoundingBoxAscent || FontSize * 0.8) + + (Metrics.actualBoundingBoxDescent || FontSize * 0.2) + + if (TextWidth <= Width * 0.9 && TextHeight <= Height * 0.9) { + Context2D.fillText(Text, Width / 2, Height / 2) + const Gray = CanvasToGrayImage(Canvas) + const Template = NormalizeBinary(BinarizeByContrast(Gray)) + TemplateCache.set(CacheKey, Template) + return Template + } + FontSize-- + } + + Context2D.font = `700 12px ${FontFamily}` + Context2D.fillStyle = 'black' + Context2D.textAlign = 'center' + Context2D.textBaseline = 'middle' + Context2D.fillText(Text, Width / 2, Height / 2) + const Template = NormalizeBinary(BinarizeByContrast(CanvasToGrayImage(Canvas))) + TemplateCache.set(CacheKey, Template) + return Template +} + +function ScoreRegionAgainstTarget(Region: BinaryImage, Target: TargetLabel, FontCandidates: readonly string[]): number { + const NormalizedRegion = NormalizeBinary(Region) + let Best = Number.POSITIVE_INFINITY + for (const FontFamily of FontCandidates) { + const Template = GetTemplate(Target, FontFamily) + const Score = XorDistance(NormalizedRegion, Template) + if (Score < Best) Best = Score + } + return Best +} + +function SelectTextRegions(Binary: BinaryImage): BoundingBox[] { + const Raw = FindConnectedComponents(Binary, 16) + const Merged = MergeNearbyBoxes(Raw, 10, 6) + return Merged.filter((Box) => { + if (Box.Width < 8 || Box.Height < 8) return false + const Ratio = Box.Width / Box.Height + return Ratio > 0.5 && Ratio < 12 + }) +} + +async function DetectFromSource(Request: WorkerDetectRequest): Promise { + const HasTransparency = HasTransparentPixelsInImageData(Request.ImageData) + const BackgroundCandidates = HasTransparency + ? Request.BackgroundCandidates + : Request.BackgroundCandidates.slice(0, 1) + + const FontCandidates = Request.FontCandidates ?? DefaultFontCandidates + const ScoreThreshold = Request.ScoreThreshold ?? 0.32 + let Best: MatchResult = null + + for (const BackgroundColor of BackgroundCandidates) { + const CompositedImageData = CompositeImageDataOnBackground(Request.ImageData, BackgroundColor) + const Gray = ImageDataToGrayImage(CompositedImageData) + const Binary = OpenClose(BinarizeByContrast(Gray)) + const Regions = SelectTextRegions(Binary) + if (Regions.length === 0) continue + + for (const Box of Regions) { + const Region = CropBinary(Binary, Box) + for (const Target of Targets) { + const Score = ScoreRegionAgainstTarget(Region, Target, FontCandidates) + if (!Best || Score < Best.Score) { + Best = { Label: Target, Score, Box } + } + } + } + } + + if (!Best) return null + if (Best.Score > ScoreThreshold) return null + return Best +} + +function ImageDataToGrayImage(Source: ImageData): GrayImage { + const { width: Width, height: Height, data: Rgba } = Source + const Gray = new Uint8ClampedArray(Width * Height) + + for (let Index = 0, Pixel = 0; Index < Rgba.length; Index += 4, Pixel++) { + const Red = Rgba[Index] + const Green = Rgba[Index + 1] + const Blue = Rgba[Index + 2] + Gray[Pixel] = Math.round(0.299 * Red + 0.587 * Green + 0.114 * Blue) + } + + return { Width, Height, Data: Gray } +} + +function HasTransparentPixelsInImageData(Source: ImageData): boolean { + const Rgba = Source.data + + for (let Index = 3; Index < Rgba.length; Index += 4) { + if (Rgba[Index] < 255) return true + } + + return false +} + +function CompositeImageDataOnBackground(Source: ImageData, BackgroundCssColor: string): ImageData { + const Canvas = CreateCanvas(Source.width, Source.height) + const Context2D = Get2DContext(Canvas) + + Context2D.fillStyle = BackgroundCssColor + Context2D.fillRect(0, 0, Source.width, Source.height) + Context2D.putImageData(Source, 0, 0) + + return Context2D.getImageData(0, 0, Source.width, Source.height) +} + +self.addEventListener('message', (Event: MessageEvent) => { + void (async () => { + const Message = Event.data + if (!Message || Message.Kind !== 'detect') return + + try { + const Result = await DetectFromSource(Message) + const Response: WorkerDetectSuccessResponse = { + Kind: 'detect-result', + RequestId: Message.RequestId, + Result, + } + self.postMessage(Response) + } catch (ErrorValue) { + const ErrorMessage = ErrorValue instanceof Error ? ErrorValue.message : String(ErrorValue) + const Response: WorkerDetectErrorResponse = { + Kind: 'detect-error', + RequestId: Message.RequestId, + Error: ErrorMessage, + } + self.postMessage(Response) + } + })() +}) + +export {} diff --git a/userscript/source/vuejsawait.ts b/userscript/source/vuejsawait.ts new file mode 100644 index 0000000..528ed1f --- /dev/null +++ b/userscript/source/vuejsawait.ts @@ -0,0 +1,85 @@ +export function AttachVueSettledEvents(TargetEl: HTMLElement, Options: { QuietMs?: number; EventName?: string; ChangeEventName?: string } = {}) { + const QuietMs = Options.QuietMs ?? 120 + const EventName = Options.EventName ?? 'vue:settled' + const ChangeEventName = Options.ChangeEventName ?? 'vue:dom-changed' + + if (!(TargetEl instanceof HTMLElement)) { + throw new TypeError('TargetEl must be an HTMLElement') + } + + let Timer: number = -1 + let Seq = 0 + let Destroyed = false + let LastMutationAt = performance.now() + + const EmitChange = (Mutations: MutationRecord[]) => { + TargetEl.dispatchEvent( + new CustomEvent(ChangeEventName, { + detail: { + Seq, + At: LastMutationAt, + MutationCount: Mutations.length, + Mutations, + }, + }), + ) + } + + const EmitSettled = () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (Destroyed) { + return + } + + TargetEl.dispatchEvent( + new CustomEvent(EventName, { + detail: { + Seq, + QuietMs, + SettledAt: performance.now(), + ElapsedSinceLastMutation: performance.now() - LastMutationAt, + Target: TargetEl, + }, + }), + ) + }) + }) + } + + const ArmSettledTimer = () => { + clearTimeout(Timer) + Timer = setTimeout(EmitSettled, QuietMs) + } + + const Observer = new MutationObserver((Mutations: MutationRecord[]) => { + Seq += 1 + LastMutationAt = performance.now() + + EmitChange(Mutations) + ArmSettledTimer() + }) + + Observer.observe(TargetEl, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }) + + ArmSettledTimer() + + return { + Disconnect() { + Destroyed = true + clearTimeout(Timer) + Observer.disconnect() + + TargetEl.dispatchEvent( + new CustomEvent('vue:observer-disconnected', { + detail: { Target: TargetEl }, + }), + ) + }, + } +} \ No newline at end of file diff --git a/userscript/tsconfig.json b/userscript/tsconfig.json index b07adf1..1c4e380 100644 --- a/userscript/tsconfig.json +++ b/userscript/tsconfig.json @@ -1,9 +1,11 @@ { "extends": "../tsconfig.json", "include": [ - "source/**/*.ts" + "source/**/*.ts", + "VM.d.ts" ], "compilerOptions": { + "rootDir": "./source", "outDir": "../dist/", "declaration": true, "skipLibCheck": true