feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
138
apps/web/scripts/check-cosmetic-filter-safety.mjs
Normal file
138
apps/web/scripts/check-cosmetic-filter-safety.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const webRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
const sourceRoot = path.join(webRoot, "src");
|
||||
const productionExtensions = new Set([".css", ".ts", ".tsx"]);
|
||||
|
||||
// 광고 차단 목록은 이 접두사의 CSS class/id를 일반 규칙으로 숨길 수 있다.
|
||||
// 외부 목록을 빌드 시 내려받지 않고, 결정적인 로컬 계약으로 위험 namespace를 금지한다.
|
||||
const dangerousUiIdentifier =
|
||||
/^(?:ad|ads|advert|advertisement|advertising|sponsor|sponsored)(?:[-_]|$)/i;
|
||||
|
||||
function lineAt(source, index) {
|
||||
return source.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
function recordFinding(findings, seen, source, filePath, token, index) {
|
||||
if (!dangerousUiIdentifier.test(token)) return;
|
||||
const line = lineAt(source, index);
|
||||
const key = `${filePath}:${line}:${token}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
findings.push({ filePath, line, token });
|
||||
}
|
||||
|
||||
export function findDangerousUiIdentifiers(source, filePath) {
|
||||
const findings = [];
|
||||
const seen = new Set();
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (extension === ".css") {
|
||||
for (const match of source.matchAll(/([.#])([A-Za-z_][A-Za-z0-9_-]*)/g)) {
|
||||
recordFinding(findings, seen, source, filePath, match[2], match.index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// TS/TSX/HTML의 정적 문자열에서 class/id 후보를 찾는다. `/admin` 같은 경로는
|
||||
// `admin`으로 분리되어 허용되고, `upload-card`처럼 중간에 ad가 있는 이름도 허용된다.
|
||||
for (const literal of source.matchAll(/(["'`])([^"'`\r\n]*)\1/g)) {
|
||||
const content = literal[2];
|
||||
const contentOffset = literal.index + 1;
|
||||
for (const tokenMatch of content.matchAll(/[A-Za-z_][A-Za-z0-9_-]*/g)) {
|
||||
recordFinding(
|
||||
findings,
|
||||
seen,
|
||||
source,
|
||||
filePath,
|
||||
tokenMatch[0],
|
||||
contentOffset + tokenMatch.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function runSelfTest() {
|
||||
const rejected = [
|
||||
['<main className="ad-root ad-section">', "fixture.tsx", ["ad-root", "ad-section"]],
|
||||
[".ads-panel, #sponsor-slot { display: block; }", "fixture.css", ["ads-panel", "sponsor-slot"]],
|
||||
];
|
||||
const accepted = [
|
||||
['<main className="vgops-root upload-card head-faceless">', "fixture.tsx"],
|
||||
[".vgops-root .brow-sad-left { display: block; }", "fixture.css"],
|
||||
['const route = "/admin";', "fixture.ts"],
|
||||
];
|
||||
|
||||
for (const [source, filePath, expectedTokens] of rejected) {
|
||||
const actualTokens = findDangerousUiIdentifiers(source, filePath).map(
|
||||
(finding) => finding.token,
|
||||
);
|
||||
for (const expectedToken of expectedTokens) {
|
||||
if (!actualTokens.includes(expectedToken)) {
|
||||
throw new Error(`자체 검사 실패: ${expectedToken} 위험 식별자를 잡지 못했습니다.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [source, filePath] of accepted) {
|
||||
const findings = findDangerousUiIdentifiers(source, filePath);
|
||||
if (findings.length > 0) {
|
||||
throw new Error(`자체 검사 실패: 안전한 식별자를 거부했습니다. ${JSON.stringify(findings)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listProductionFiles(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listProductionFiles(absolutePath)));
|
||||
} else if (productionExtensions.has(path.extname(entry.name).toLowerCase())) {
|
||||
files.push(absolutePath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function checkProductionSources() {
|
||||
const files = [...(await listProductionFiles(sourceRoot)), path.join(webRoot, "index.html")];
|
||||
const findings = [];
|
||||
|
||||
for (const absolutePath of files) {
|
||||
const source = await fs.readFile(absolutePath, "utf8");
|
||||
const relativePath = path.relative(webRoot, absolutePath).replaceAll("\\", "/");
|
||||
findings.push(...findDangerousUiIdentifiers(source, relativePath));
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error(
|
||||
[
|
||||
"광고 차단 필터 안전성 검사 실패",
|
||||
...findings.map(
|
||||
({ filePath, line, token }) =>
|
||||
`- ${filePath}:${line}: ${JSON.stringify(token)}은(는) EasyList 계열 일반 숨김 규칙과 충돌할 수 있습니다. 제품 전용 중립 namespace를 사용하세요.`,
|
||||
),
|
||||
].join("\n"),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`광고 차단 필터 안전성 검사 통과: 프로덕션 파일 ${files.length}개에서 위험 UI namespace가 없습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
runSelfTest();
|
||||
|
||||
if (process.argv.includes("--self-test")) {
|
||||
console.log("광고 차단 필터 안전성 자체 검사 통과");
|
||||
} else {
|
||||
await checkProductionSources();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue