This commit is contained in:
root
2026-05-19 02:25:21 +00:00
parent fa422fb7b8
commit e1d7251545
7 changed files with 345 additions and 29 deletions

View File

@@ -12,6 +12,45 @@ prune_path() {
rm -rf "$target" rm -rf "$target"
} }
ensure_profile_preferences() {
if ! command -v python3 >/dev/null 2>&1; then
return 0
fi
mkdir -p "$profile_dir/Default"
PROFILE_DIR="$profile_dir" python3 - <<'PY'
import json
import os
from pathlib import Path
profile_dir = Path(os.environ["PROFILE_DIR"])
def load_json(path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def save_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(data, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
for relative_path in ("Default/Preferences", "Local State"):
path = profile_dir / relative_path
data = load_json(path)
extensions = data.setdefault("extensions", {})
ui = extensions.setdefault("ui", {})
ui["developer_mode"] = True
profile = data.setdefault("profile", {})
profile["exit_type"] = "Normal"
save_json(path, data)
PY
}
find_bin() { find_bin() {
if [ "$family" = "google-chrome" ]; then if [ "$family" = "google-chrome" ]; then
command -v google-chrome 2>/dev/null || command -v google-chrome-stable 2>/dev/null || true command -v google-chrome 2>/dev/null || command -v google-chrome-stable 2>/dev/null || true
@@ -67,25 +106,74 @@ prune_path "$profile_dir/Default/Code Cache"
prune_path "$profile_dir/Default/GPUCache" prune_path "$profile_dir/Default/GPUCache"
prune_path "$profile_dir/Default/DawnGraphiteCache" prune_path "$profile_dir/Default/DawnGraphiteCache"
prune_path "$profile_dir/Default/DawnWebGPUCache" prune_path "$profile_dir/Default/DawnWebGPUCache"
ensure_profile_preferences
exec "$browser_bin" \ browser_class="google-chrome"
${render_flags} \ if [ "$family" != "google-chrome" ]; then
--window-position=0,0 \ browser_class="chromium"
--display="${DISPLAY:?DISPLAY is required}" \ fi
--user-data-dir="$profile_dir" \
--password-store=basic \ launch_browser() {
--no-first-run \ "$browser_bin" \
--start-minimized \ ${render_flags} \
--force-dark-mode \ --window-position=0,0 \
--disable-file-system \ --display="${DISPLAY:?DISPLAY is required}" \
--disable-dev-shm-usage \ --remote-debugging-address=127.0.0.1 \
--disable-background-networking \ --remote-debugging-port=9222 \
--disable-breakpad \ --user-data-dir="$profile_dir" \
--disable-default-apps \ --password-store=basic \
--disable-extensions \ --no-first-run \
--disable-sync \ --new-window \
--disable-translate \ --start-maximized \
--metrics-recording-only \ --force-dark-mode \
--no-default-browser-check \ --disable-background-mode \
--no-first-run \ --disable-dev-shm-usage \
"$start_url" --disable-background-networking \
--disable-breakpad \
--disable-default-apps \
--disable-sync \
--disable-translate \
--metrics-recording-only \
--no-default-browser-check \
--no-first-run \
"$start_url" &
browser_pid=$!
}
visible_window_count() {
if ! command -v xdotool >/dev/null 2>&1; then
return 1
fi
DISPLAY="${DISPLAY:?DISPLAY is required}" xdotool search --onlyvisible --class "$browser_class" 2>/dev/null | wc -l | tr -d ' '
}
launch_browser
seen_window=0
startup_misses=0
closed_misses=0
while kill -0 "$browser_pid" 2>/dev/null; do
window_count="$(visible_window_count || true)"
if [ -n "${window_count:-}" ] && [ "$window_count" -gt 0 ] 2>/dev/null; then
seen_window=1
startup_misses=0
closed_misses=0
else
if [ "$seen_window" -eq 0 ]; then
startup_misses=$((startup_misses + 1))
if [ "$startup_misses" -ge 15 ]; then
kill -TERM "$browser_pid" 2>/dev/null || true
break
fi
else
closed_misses=$((closed_misses + 1))
if [ "$closed_misses" -ge 3 ]; then
kill -TERM "$browser_pid" 2>/dev/null || true
break
fi
fi
fi
sleep 2
done
wait "$browser_pid"

View File

@@ -40,8 +40,9 @@ services:
- "${NEKO_PROFILE_HOST_DIR_1}:/home/neko/.config/google-chrome" - "${NEKO_PROFILE_HOST_DIR_1}:/home/neko/.config/google-chrome"
- "./policies:/etc/chromium/policies/managed:ro" - "./policies:/etc/chromium/policies/managed:ro"
- "./policies:/etc/opt/chrome/policies/managed:ro" - "./policies:/etc/opt/chrome/policies/managed:ro"
- "./provisioning:/var/www/provisioning:ro"
- "./browser-launch.sh:/etc/neko/browser-launch.sh:ro" - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro"
- "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro" - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro"
neko2: neko2:
<<: *neko-common <<: *neko-common
@@ -61,8 +62,9 @@ services:
- "${NEKO_PROFILE_HOST_DIR_2}:/home/neko/.config/google-chrome" - "${NEKO_PROFILE_HOST_DIR_2}:/home/neko/.config/google-chrome"
- "./policies:/etc/chromium/policies/managed:ro" - "./policies:/etc/chromium/policies/managed:ro"
- "./policies:/etc/opt/chrome/policies/managed:ro" - "./policies:/etc/opt/chrome/policies/managed:ro"
- "./provisioning:/var/www/provisioning:ro"
- "./browser-launch.sh:/etc/neko/browser-launch.sh:ro" - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro"
- "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro" - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro"
neko3: neko3:
<<: *neko-common <<: *neko-common
@@ -82,5 +84,6 @@ services:
- "${NEKO_PROFILE_HOST_DIR_3}:/home/neko/.config/google-chrome" - "${NEKO_PROFILE_HOST_DIR_3}:/home/neko/.config/google-chrome"
- "./policies:/etc/chromium/policies/managed:ro" - "./policies:/etc/chromium/policies/managed:ro"
- "./policies:/etc/opt/chrome/policies/managed:ro" - "./policies:/etc/opt/chrome/policies/managed:ro"
- "./provisioning:/var/www/provisioning:ro"
- "./browser-launch.sh:/etc/neko/browser-launch.sh:ro" - "./browser-launch.sh:/etc/neko/browser-launch.sh:ro"
- "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro" - "./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro"

View File

@@ -17,5 +17,19 @@
"VideoCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"], "VideoCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"],
"AudioCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"], "AudioCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"],
"DeveloperToolsAvailability": 1, "DeveloperToolsAvailability": 1,
"DefaultGeolocationSetting": 1 "DefaultGeolocationSetting": 1,
"3rdparty": {
"extensions": {
"dhdgffkkebhmkfjojejmpbldmpobfkfo": {
"jsonImport": [
{
"hash": "1:1b41fa75bfe51a730bb246022f619811bc508da3176463c86009fe5a0a9f16b5",
"url": "http://127.0.0.1:8080/provisioning/tampermonkey-provisioning.json",
"haltOnError": false,
"installAsSystemScripts": true
}
]
}
}
}
} }

View File

@@ -0,0 +1,15 @@
{
"version": "1",
"scripts": [
{
"name": "YouTube Studio Auto Dismiss",
"file_url": "https://greasyfork.org/scripts/557378-youtube-studio-auto-dismiss/code/YouTube%20Studio%20Auto%20Dismiss.user.js",
"source": "// ==UserScript==\n// @name YouTube Studio Auto Dismiss\n// @namespace http://tampermonkey.net/\n// @version 1.1\n// @description 每5秒自动检测并点击 YouTube Studio 直播页面的 Dismiss 按钮,支持延迟加载\n// @author You\n// @match https://studio.youtube.com/channel/*/livestreaming\n// @match https://studio.youtube.com/video/*/livestreaming\n// @match https://studio.youtube.com/*/livestreaming\n// @license MIT\n// @grant none\n// @downloadURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.user.js\n// @updateURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.meta.js\n// ==/UserScript==\n\n(function () {\n \"use strict\";\n\n function triggerClick(el) {\n if (!el) return;\n const evt = new MouseEvent(\"click\", {\n view: window,\n bubbles: true,\n cancelable: true,\n });\n el.dispatchEvent(evt);\n console.log(\"Dismiss 按钮已点击:\", el, \"父元素:\", el.parentElement);\n }\n\n function findDismiss(root = document) {\n const allElements = root.querySelectorAll(\"button\");\n for (const el of allElements) {\n try {\n const text = el.innerText ? el.innerText.trim() : \"\";\n if (text === \"Dismiss\" && isElementVisible(el)) {\n console.log(\"找到 Dismiss 按钮:\", el);\n return el;\n }\n\n if (el.shadowRoot) {\n const shadowBtn = findDismiss(el.shadowRoot);\n if (shadowBtn) return shadowBtn;\n }\n } catch (error) {\n console.log(\"检查元素时出错:\", error);\n }\n }\n return null;\n }\n\n function isElementVisible(el) {\n const style = window.getComputedStyle(el);\n return style.display !== \"none\" && style.visibility !== \"hidden\" && el.offsetParent !== null;\n }\n\n function checkAndClick() {\n const btn = findDismiss();\n if (btn) {\n triggerClick(btn);\n } else {\n console.log(\"未找到 Dismiss 按钮或按钮不可见\");\n }\n }\n\n if (document.readyState === \"complete\") {\n checkAndClick();\n } else {\n window.addEventListener(\"load\", checkAndClick, { once: true });\n }\n\n setInterval(checkAndClick, 5000);\n})();\n",
"enabled": true,
"position": 1
}
],
"settings": {
"runtime_content_mode": "content"
}
}

View File

@@ -0,0 +1,72 @@
// ==UserScript==
// @name YouTube Studio Auto Dismiss
// @namespace http://tampermonkey.net/
// @version 1.1
// @description 每5秒自动检测并点击 YouTube Studio 直播页面的 Dismiss 按钮,支持延迟加载
// @author You
// @match https://studio.youtube.com/channel/*/livestreaming
// @match https://studio.youtube.com/video/*/livestreaming
// @match https://studio.youtube.com/*/livestreaming
// @license MIT
// @grant none
// @downloadURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.user.js
// @updateURL https://update.greasyfork.org/scripts/557378/YouTube%20Studio%20Auto%20Dismiss.meta.js
// ==/UserScript==
(function () {
"use strict";
function triggerClick(el) {
if (!el) return;
const evt = new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: true,
});
el.dispatchEvent(evt);
console.log("Dismiss 按钮已点击:", el, "父元素:", el.parentElement);
}
function findDismiss(root = document) {
const allElements = root.querySelectorAll("button");
for (const el of allElements) {
try {
const text = el.innerText ? el.innerText.trim() : "";
if (text === "Dismiss" && isElementVisible(el)) {
console.log("找到 Dismiss 按钮:", el);
return el;
}
if (el.shadowRoot) {
const shadowBtn = findDismiss(el.shadowRoot);
if (shadowBtn) return shadowBtn;
}
} catch (error) {
console.log("检查元素时出错:", error);
}
}
return null;
}
function isElementVisible(el) {
const style = window.getComputedStyle(el);
return style.display !== "none" && style.visibility !== "hidden" && el.offsetParent !== null;
}
function checkAndClick() {
const btn = findDismiss();
if (btn) {
triggerClick(btn);
} else {
console.log("未找到 Dismiss 按钮或按钮不可见");
}
}
if (document.readyState === "complete") {
checkAndClick();
} else {
window.addEventListener("load", checkAndClick, { once: true });
}
setInterval(checkAndClick, 5000);
})();

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import importlib.util
import json import json
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -15,8 +16,14 @@ class NekoConfigTests(unittest.TestCase):
self.assertIn("--disable-software-rasterizer", script) self.assertIn("--disable-software-rasterizer", script)
self.assertIn("--use-gl=swiftshader", script) self.assertIn("--use-gl=swiftshader", script)
self.assertIn("NEKO_BROWSER_RENDER_MODE", script) self.assertIn("NEKO_BROWSER_RENDER_MODE", script)
self.assertIn("--start-minimized", script) self.assertIn("--start-maximized", script)
self.assertIn("--disable-background-mode", script)
self.assertIn("--disable-background-networking", script) self.assertIn("--disable-background-networking", script)
self.assertNotIn("--disable-extensions", script)
self.assertIn('ui["developer_mode"] = True', script)
self.assertIn('"Local State"', script)
self.assertIn('browser_class="google-chrome"', script)
self.assertIn('xdotool search --onlyvisible --class "$browser_class"', script)
self.assertIn('prune_path "$profile_dir/BrowserMetrics"', script) self.assertIn('prune_path "$profile_dir/BrowserMetrics"', script)
self.assertIn('prune_path "$profile_dir/Crash Reports"', script) self.assertIn('prune_path "$profile_dir/Crash Reports"', script)
self.assertIn('prune_path "$profile_dir/SingletonLock"', script) self.assertIn('prune_path "$profile_dir/SingletonLock"', script)
@@ -29,6 +36,10 @@ class NekoConfigTests(unittest.TestCase):
self.assertTrue(policies["VideoCaptureAllowed"]) self.assertTrue(policies["VideoCaptureAllowed"])
self.assertTrue(policies["AudioCaptureAllowed"]) self.assertTrue(policies["AudioCaptureAllowed"])
self.assertEqual(policies["DeveloperToolsAvailability"], 1) self.assertEqual(policies["DeveloperToolsAvailability"], 1)
tm_policy = policies["3rdparty"]["extensions"]["dhdgffkkebhmkfjojejmpbldmpobfkfo"]["jsonImport"][0]
self.assertEqual(tm_policy["url"], "http://127.0.0.1:8080/provisioning/tampermonkey-provisioning.json")
self.assertTrue(tm_policy["hash"].startswith("1:"))
self.assertTrue(tm_policy["installAsSystemScripts"])
def test_google_chrome_supervisor_uses_direct_stable_command(self) -> None: def test_google_chrome_supervisor_uses_direct_stable_command(self) -> None:
conf = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8") conf = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8")
@@ -53,16 +64,16 @@ class NekoConfigTests(unittest.TestCase):
def test_neko_compose_mounts_only_selected_supervisor_conf(self) -> None: def test_neko_compose_mounts_only_selected_supervisor_conf(self) -> None:
compose = (ROOT / "services" / "neko" / "docker-compose.yml").read_text(encoding="utf-8") compose = (ROOT / "services" / "neko" / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro", compose) self.assertIn("./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/${NEKO_BROWSER_SUPERVISOR_CONF}:ro", compose)
self.assertIn("./browser-launch.sh:/etc/neko/browser-launch.sh:ro", compose) self.assertIn("./browser-launch.sh:/etc/neko/browser-launch.sh:ro", compose)
self.assertIn("./provisioning:/var/www/provisioning:ro", compose)
self.assertIn("container_name: live-neko-1", compose) self.assertIn("container_name: live-neko-1", compose)
self.assertIn("container_name: live-neko-2", compose) self.assertIn("container_name: live-neko-2", compose)
self.assertIn("container_name: live-neko-3", compose) self.assertIn("container_name: live-neko-3", compose)
self.assertIn("${NEKO_HTTP_PORT_1}:8080", compose) self.assertIn("${NEKO_HTTP_PORT_1}:8080", compose)
self.assertIn("${NEKO_HTTP_PORT_2}:8080", compose) self.assertIn("${NEKO_HTTP_PORT_2}:8080", compose)
self.assertIn("${NEKO_HTTP_PORT_3}:8080", compose) self.assertIn("${NEKO_HTTP_PORT_3}:8080", compose)
self.assertNotIn("./chromium.conf:/etc/neko/supervisord/chromium.conf:ro", compose) self.assertNotIn("/etc/neko/supervisord/browser.conf:ro", compose)
self.assertNotIn("./google-chrome.conf:/etc/neko/supervisord/google-chrome.conf:ro", compose)
def test_supervisor_uses_browser_launch_script(self) -> None: def test_supervisor_uses_browser_launch_script(self) -> None:
chrome = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8") chrome = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8")
@@ -72,6 +83,28 @@ class NekoConfigTests(unittest.TestCase):
self.assertNotIn("--start-maximized", chrome) self.assertNotIn("--start-maximized", chrome)
self.assertNotIn("--start-maximized", chromium) self.assertNotIn("--start-maximized", chromium)
def test_tampermonkey_provisioning_matches_policy_hash(self) -> None:
provisioning_path = ROOT / "services" / "neko" / "provisioning" / "tampermonkey-provisioning.json"
userscript_path = ROOT / "services" / "neko" / "provisioning" / "youtube-studio-auto-dismiss.user.js"
payload = json.loads(provisioning_path.read_text(encoding="utf-8"))
self.assertEqual(payload["version"], "1")
self.assertEqual(payload["settings"]["runtime_content_mode"], "content")
self.assertEqual(payload["scripts"][0]["file_url"], "https://greasyfork.org/scripts/557378-youtube-studio-auto-dismiss/code/YouTube%20Studio%20Auto%20Dismiss.user.js")
self.assertEqual(payload["scripts"][0]["source"], userscript_path.read_text(encoding="utf-8"))
spec = importlib.util.spec_from_file_location(
"render_tampermonkey_provisioning",
ROOT / "tools" / "render_tampermonkey_provisioning.py",
)
self.assertIsNotNone(spec)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
policies = json.loads((ROOT / "services" / "neko" / "policies" / "policies.json").read_text(encoding="utf-8"))
expected_hash = policies["3rdparty"]["extensions"]["dhdgffkkebhmkfjojejmpbldmpobfkfo"]["jsonImport"][0]["hash"]
self.assertEqual(expected_hash, f"1:{module.tm_hash(payload)}")
def test_neko_scripts_use_family_specific_profile_dirs(self) -> None: def test_neko_scripts_use_family_specific_profile_dirs(self) -> None:
service_ctl = (ROOT / "scripts" / "linux" / "service_ctl.sh").read_text(encoding="utf-8") service_ctl = (ROOT / "scripts" / "linux" / "service_ctl.sh").read_text(encoding="utf-8")
common = (ROOT / "scripts" / "linux" / "lib" / "common.sh").read_text(encoding="utf-8") common = (ROOT / "scripts" / "linux" / "lib" / "common.sh").read_text(encoding="utf-8")

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SCRIPT = ROOT / "services" / "neko" / "provisioning" / "youtube-studio-auto-dismiss.user.js"
DEFAULT_OUTPUT = ROOT / "services" / "neko" / "provisioning" / "tampermonkey-provisioning.json"
GREASYFORK_URL = "https://greasyfork.org/scripts/557378-youtube-studio-auto-dismiss/code/YouTube%20Studio%20Auto%20Dismiss.user.js"
def tm_sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def tm_hash(value: Any, seen: list[int] | None = None) -> str:
if seen is None:
seen = []
if value is None:
return tm_sha256("object:null")
if isinstance(value, dict):
ident = id(value)
if ident in seen:
raise ValueError("Found circular structure")
seen.append(ident)
try:
parts = [tm_hash(value[key], seen) for key in sorted(value)]
finally:
seen.pop()
return tm_sha256("".join(parts))
if isinstance(value, list):
ident = id(value)
if ident in seen:
raise ValueError("Found circular structure")
seen.append(ident)
try:
parts = [tm_hash(item, seen) for item in value]
finally:
seen.pop()
return tm_sha256("".join(parts))
if isinstance(value, bool):
return tm_sha256(f"boolean:{str(value).lower()}")
if isinstance(value, (int, float)) and not isinstance(value, bool):
return tm_sha256(f"number:{value}")
if isinstance(value, str):
return tm_sha256(f"string:{value}")
raise TypeError(f"Unsupported provisioning value: {type(value)!r}")
def build_payload(script_text: str) -> dict[str, Any]:
return {
"version": "1",
"scripts": [
{
"name": "YouTube Studio Auto Dismiss",
"file_url": GREASYFORK_URL,
"source": script_text,
"enabled": True,
"position": 1,
}
],
"settings": {
"runtime_content_mode": "content",
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Render Tampermonkey provisioning JSON and hash.")
parser.add_argument("--script", type=Path, default=DEFAULT_SCRIPT)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--write", action="store_true", help="Write the rendered JSON to --output.")
args = parser.parse_args()
script_text = args.script.read_text(encoding="utf-8").strip() + "\n"
payload = build_payload(script_text)
rendered = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
if args.write:
args.output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
print(f"hash=1:{tm_hash(payload)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())