reborn-watermark 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/install.mjs +136 -0
- package/package.json +35 -0
- package/skill/SKILL.md +67 -0
- package/skill/detect.py +205 -0
- package//354/225/210/353/202/264/354/212/244/355/202/254.md +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 REBORN LABS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# AI 워터마크 검사기
|
|
2
|
+
|
|
3
|
+
글·문서에 **눈에 보이지 않는 문자**가 숨어 있는지 검사하는 클로드 코드 스킬입니다.
|
|
4
|
+
폭이 0이라 화면에도 인쇄에도 안 나오지만, 파일에는 남아서 복사·붙여넣기를 따라다닙니다.
|
|
5
|
+
|
|
6
|
+
## 설치
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx reborn-watermark
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
클로드 코드 안에서라면 `!npx reborn-watermark` 라고 치거나, 그냥 **"AI 워터마크 검사기 설치해줘"** 라고 말해도 됩니다.
|
|
13
|
+
|
|
14
|
+
> ★깐 다음 **클로드 코드를 껐다 켜야** 적용됩니다.
|
|
15
|
+
> 지금 상태만 보려면 `npx reborn-watermark --check`
|
|
16
|
+
|
|
17
|
+
## 쓰는 법
|
|
18
|
+
|
|
19
|
+
클로드에게 그냥 말하세요.
|
|
20
|
+
|
|
21
|
+
- "이 글 워터마크 검사해 줘" — 붙여넣은 글을 바로 훑습니다
|
|
22
|
+
- "보고서.docx 에 숨은 문자 있는지 봐 줘" — 파일도 됩니다
|
|
23
|
+
- "숨은 문자 빼고 저장해 줘" — 지웁니다 (원본은 안 건드립니다)
|
|
24
|
+
|
|
25
|
+
직접 돌리셔도 됩니다.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python detect.py <파일 또는 폴더> # 사람이 읽는 표
|
|
29
|
+
python detect.py <대상> --json # 기계가 읽는 형식
|
|
30
|
+
python detect.py <파일> --strip -o <출력파일>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 무엇을 찾나
|
|
34
|
+
|
|
35
|
+
| 종류 | 예 | 왜 문제인가 |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| 폭 0 문자 | `U+200B` ZWSP · `U+200C` ZWNJ · `U+200D` ZWJ · `U+FEFF` | 눈으로는 **절대** 못 찾습니다 |
|
|
38
|
+
| 태그 문자 | `U+E0000`~`U+E007F` | 이 안에 문자열을 **통째로** 숨길 수 있습니다 |
|
|
39
|
+
| 방향 제어 | `U+202E` RLO 등 | 글자 순서를 뒤집어 보이게 만듭니다 |
|
|
40
|
+
| 사적 영역 | PUA | 표준에 없는 값입니다 |
|
|
41
|
+
| 닮은 공백 | `U+00A0` · `U+2009` · `U+3000` | 공백처럼 보이지만 다른 문자입니다 |
|
|
42
|
+
|
|
43
|
+
## ★무엇을 하지 않나
|
|
44
|
+
|
|
45
|
+
**문체로 AI를 추정하지 않습니다.**
|
|
46
|
+
"이 글은 AI가 쓴 것 같다" 같은 판정은 하지 않습니다 — 확률이라 틀리고, 틀리면 사람을 잘못 의심하게 됩니다.
|
|
47
|
+
|
|
48
|
+
이 도구는 **파일에 실제로 들어 있는 코드포인트**만 말합니다. 있으면 있다고, 없으면 없다고 합니다.
|
|
49
|
+
그래서 결과를 반박할 수가 없습니다.
|
|
50
|
+
|
|
51
|
+
## 결과 읽는 법
|
|
52
|
+
|
|
53
|
+
- **0건** — 숨은 문자가 없습니다. 그대로 쓰셔도 됩니다.
|
|
54
|
+
- **이모지 근처 `U+200D`** — 정상입니다. 🧑💻 같은 결합 이모지가 ZWJ 로 붙어 있는 것이라 **세지 않습니다.**
|
|
55
|
+
- **그 밖의 폭 0 문자** — 의도치 않게 들어온 것입니다. 지우고 쓰시는 편이 안전합니다.
|
|
56
|
+
|
|
57
|
+
## 이 설치기가 남들과 다른 점
|
|
58
|
+
|
|
59
|
+
**깔고 끝내지 않습니다.** 복사는 조용히 실패하기 때문에, 종료코드로 판정하지 않습니다.
|
|
60
|
+
|
|
61
|
+
깐 뒤에 검사기를 **양방향으로 시험합니다.**
|
|
62
|
+
|
|
63
|
+
- 깨끗한 문장을 넣어 → **0건**이어야 합니다 (오탐이 없나)
|
|
64
|
+
- 숨은 문자를 심은 문장을 넣어 → **잡아야** 합니다 (미탐이 없나)
|
|
65
|
+
- 결합 이모지를 넣어 → **0건**이어야 합니다 (정상을 잡지 않나)
|
|
66
|
+
|
|
67
|
+
셋 중 하나라도 어긋나면 설치 실패로 보고합니다.
|
|
68
|
+
검사기가 전부 통과시키거나 전부 잡아내면 그건 검사가 아니라 고장이니까요.
|
|
69
|
+
|
|
70
|
+
## 실제로 재봤습니다
|
|
71
|
+
|
|
72
|
+
만들고 나서 세션 원문 **2,357개를 전수로** 돌려 봤습니다.
|
|
73
|
+
|
|
74
|
+
| 누가 쓴 것 | 글자 | 숨은 문자 | 100만 자당 |
|
|
75
|
+
|---|---:|---:|---:|
|
|
76
|
+
| 클로드 | 9,032,197 | **1** | 0.1 |
|
|
77
|
+
| 사람(붙여넣기 포함) | 12,937,190 | **239** | 18.5 |
|
|
78
|
+
|
|
79
|
+
**167배 차이입니다.** 방향이 소문과 반대였습니다.
|
|
80
|
+
위험한 건 AI가 아니라 **웹·메일에서 복사해 온 텍스트**였습니다(마케팅 메일 한 통에 570개가 들어 있던 실례가 있습니다).
|
|
81
|
+
|
|
82
|
+
## 요구사항
|
|
83
|
+
|
|
84
|
+
- Node.js 18 이상 (설치기)
|
|
85
|
+
- Python 3.8 이상 (검사기 — 표준 라이브러리만 씁니다. 설치할 것이 없습니다)
|
|
86
|
+
|
|
87
|
+
## 라이선스
|
|
88
|
+
|
|
89
|
+
MIT · 완전 무료 · npm 공개. 가져다 쓰고, 고치고, 팔아도 됩니다.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
REBORN LABS · https://rebornlabs.kr
|
package/install.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* AI 워터마크 검사기 — 설치기.
|
|
4
|
+
*
|
|
5
|
+
* ★복사는 조용히 실패한다. 그래서 종료코드로 판정하지 않는다.
|
|
6
|
+
* 깐 뒤에 파일이 실제로 있는지, 그리고 **검사기가 실제로 잡는지** 다시 확인한다.
|
|
7
|
+
*
|
|
8
|
+
* ★★이 설치기의 값은 설치가 아니라 **설치 뒤 시험**이다.
|
|
9
|
+
* 파일이 있다는 것만으로 통과시키지 않는다 — 일부러 숨은 문자를 심은 문장과
|
|
10
|
+
* 깨끗한 문장을 둘 다 넣어 **양방향**으로 돌려 본다. 한쪽만 맞으면 불합격이다.
|
|
11
|
+
* (검사기가 전부 통과시키거나 전부 잡아내면 그건 고장이지 검사가 아니다.)
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join, relative } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const HOME = homedir();
|
|
20
|
+
const 여기 = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const 확인만 = process.argv.includes("--check");
|
|
22
|
+
|
|
23
|
+
const c = { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", d: "\x1b[90m", cy: "\x1b[36m", b: "\x1b[1m", 0: "\x1b[0m" };
|
|
24
|
+
const 스킬이름 = "ai-watermark-check";
|
|
25
|
+
const 원본 = join(여기, "skill");
|
|
26
|
+
const 대상 = join(HOME, ".claude", "skills", 스킬이름);
|
|
27
|
+
|
|
28
|
+
console.log(`\n${c.b}AI 워터마크 검사기${c[0]} ${c.d}· 클로드 코드 스킬${c[0]}`);
|
|
29
|
+
console.log(`${c.d}글에 눈에 보이지 않는 문자가 숨어 있는지 검사합니다.${c[0]}\n`);
|
|
30
|
+
|
|
31
|
+
function 파일목록(뿌리) {
|
|
32
|
+
const 모음 = [];
|
|
33
|
+
for (const 이름 of readdirSync(뿌리)) {
|
|
34
|
+
const p = join(뿌리, 이름);
|
|
35
|
+
if (statSync(p).isDirectory()) 모음.push(...파일목록(p));
|
|
36
|
+
else 모음.push(p);
|
|
37
|
+
}
|
|
38
|
+
return 모음;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 파이썬을 찾는다 — 이름만 믿지 않고 실제로 돌려 본다 [[python-interpreter-must-be-probed]] */
|
|
42
|
+
function 파이썬() {
|
|
43
|
+
for (const bin of [process.env.PYTHON, "python", "python3", "py"].filter(Boolean)) {
|
|
44
|
+
const r = spawnSync(bin, ["-c", "print(1)"], { encoding: "utf8", timeout: 20000, windowsHide: true });
|
|
45
|
+
if (r.status === 0 && String(r.stdout).trim() === "1") return bin;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ★양방향 시험 — 깨끗한 문장은 0건, 심은 문장은 잡아야 한다. 한쪽만 맞으면 고장이다. */
|
|
51
|
+
function 검사() {
|
|
52
|
+
const md = join(대상, "SKILL.md");
|
|
53
|
+
if (!existsSync(md)) return { 있음: false, 이유: "SKILL.md 가 없다" };
|
|
54
|
+
const 본문 = readFileSync(md, "utf8");
|
|
55
|
+
if (!new RegExp("^---[\\s\\S]*?\\bname:\\s*" + 스킬이름 + "\\b", "m").test(본문))
|
|
56
|
+
return { 있음: false, 이유: "SKILL.md 의 머리말이 깨졌다(name 을 못 읽는다)" };
|
|
57
|
+
|
|
58
|
+
const py = join(대상, "detect.py");
|
|
59
|
+
if (!existsSync(py)) return { 있음: false, 이유: "detect.py 가 없다" };
|
|
60
|
+
|
|
61
|
+
const bin = 파이썬();
|
|
62
|
+
if (!bin) return { 있음: true, 시험: null, 이유: "파이썬을 못 찾아 동작 시험은 건너뛴다" };
|
|
63
|
+
|
|
64
|
+
const 코드 = [
|
|
65
|
+
"import sys,io,json",
|
|
66
|
+
"sys.path.insert(0, sys.argv[1])",
|
|
67
|
+
"from detect import scan_text",
|
|
68
|
+
// 깨끗 / 심음(ZWSP·ZWNJ) / 결합 이모지 — 셋을 한 번에
|
|
69
|
+
"a = len(scan_text('\\uc815\\uc0c1 \\ubb38\\uc7a5\\uc785\\ub2c8\\ub2e4 clean'))",
|
|
70
|
+
"b = len(scan_text('\\uc228\\uc740\\u200b\\ubb38\\uc790\\u200c\\uc785\\ub2c8\\ub2e4'))",
|
|
71
|
+
"e = len(scan_text('\\U0001F9D1\\u200d\\U0001F4BB'))",
|
|
72
|
+
"print(json.dumps([a, b, e]))",
|
|
73
|
+
].join("\n");
|
|
74
|
+
const r = spawnSync(bin, ["-c", 코드, 대상],
|
|
75
|
+
{ encoding: "utf8", timeout: 60000, windowsHide: true, env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" } });
|
|
76
|
+
if (r.status !== 0) return { 있음: false, 이유: `detect.py 를 못 돌렸다 — ${String(r.stderr).trim().split("\n").pop()}` };
|
|
77
|
+
|
|
78
|
+
let v;
|
|
79
|
+
try { v = JSON.parse(String(r.stdout).trim().split("\n").pop()); }
|
|
80
|
+
catch { return { 있음: false, 이유: "시험 출력이 이상하다" }; }
|
|
81
|
+
const [깨끗, 심음, 이모지] = v;
|
|
82
|
+
if (깨끗 !== 0) return { 있음: false, 이유: `깨끗한 문장을 ${깨끗}건으로 잡는다 — 오탐` };
|
|
83
|
+
if (심음 < 2) return { 있음: false, 이유: `심어 둔 2건 중 ${심음}건만 잡는다 — 미탐` };
|
|
84
|
+
if (이모지 !== 0) return { 있음: false, 이유: `결합 이모지를 ${이모지}건으로 잡는다 — 오탐` };
|
|
85
|
+
return { 있음: true, 시험: { 깨끗, 심음, 이모지 } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const 지금 = 검사();
|
|
89
|
+
|
|
90
|
+
if (확인만) {
|
|
91
|
+
if (지금.있음) {
|
|
92
|
+
const t = 지금.시험;
|
|
93
|
+
console.log(`${c.g}✓ 설치되어 있습니다${c[0]} ${c.d}(${대상})${c[0]}`);
|
|
94
|
+
console.log(t ? `${c.d} 양방향 시험 통과 — 깨끗 ${t.깨끗}건 / 심음 ${t.심음}건 / 이모지 ${t.이모지}건${c[0]}\n`
|
|
95
|
+
: `${c.y} ${지금.이유}${c[0]}\n`);
|
|
96
|
+
} else console.log(`${c.y}· 아직 없습니다${c[0]} ${c.d}— ${지금.이유}${c[0]}\n`);
|
|
97
|
+
process.exit(지금.있음 ? 0 : 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ── 설치 ───────────────────────────────────────────────────────────── */
|
|
101
|
+
let 쓴수 = 0;
|
|
102
|
+
for (const src of 파일목록(원본)) {
|
|
103
|
+
const 상대 = relative(원본, src);
|
|
104
|
+
const dst = join(대상, 상대);
|
|
105
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
106
|
+
writeFileSync(dst, readFileSync(src));
|
|
107
|
+
쓴수++;
|
|
108
|
+
console.log(` ${c.d}·${c[0]} ${상대}`);
|
|
109
|
+
}
|
|
110
|
+
console.log(`\n${c.d}${쓴수}개 파일 → ${대상}${c[0]}`);
|
|
111
|
+
|
|
112
|
+
/* ── ★설치가 아니라 설치 뒤 시험이 이 설치기의 값이다 ─────────────────── */
|
|
113
|
+
const 뒤 = 검사();
|
|
114
|
+
if (!뒤.있음) {
|
|
115
|
+
console.error(`\n${c.r}✗ 깔았는데 확인에서 걸렸습니다${c[0]} — ${뒤.이유}`);
|
|
116
|
+
console.error(` ${c.d}위 경로를 직접 열어 확인해 주세요.${c[0]}\n`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (뒤.시험) {
|
|
121
|
+
const t = 뒤.시험;
|
|
122
|
+
console.log(`\n${c.g}${c.b}✓ 설치 완료${c[0]} ${c.d}— 양방향 시험까지 끝났습니다.${c[0]}`);
|
|
123
|
+
console.log(`${c.d} 깨끗한 문장 ${t.깨끗}건 · 숨은 문자 심은 문장 ${t.심음}건 · 결합 이모지 ${t.이모지}건(정상은 안 셉니다)${c[0]}`);
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`\n${c.g}${c.b}✓ 설치 완료${c[0]}`);
|
|
126
|
+
console.log(`${c.y} ${뒤.이유}${c[0]}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`\n${c.y}★ 클로드 코드를 껐다 켜야 적용됩니다.${c[0]}`);
|
|
130
|
+
console.log(`\n${c.b}쓰는 법${c[0]}`);
|
|
131
|
+
console.log(` 클로드에게 그냥 말하세요 — ${c.cy}"이 글 워터마크 검사해 줘"${c[0]}`);
|
|
132
|
+
console.log(` ${c.d}파일도 됩니다 — "보고서.docx 에 숨은 문자 있는지 봐 줘"${c[0]}`);
|
|
133
|
+
console.log(` ${c.d}지우려면 — "숨은 문자 빼고 저장해 줘" (결합 이모지는 남깁니다)${c[0]}\n`);
|
|
134
|
+
console.log(`${c.d}설명 · https://rebornlabs.kr/watermark?utm_source=installer&utm_medium=cli&utm_campaign=watermark${c[0]}`);
|
|
135
|
+
/* ★[[funnel-free-to-paid-join]] — 무료 배포물 안에 유료 제품 안내를 둔다. 숨기지 않는다. */
|
|
136
|
+
console.log(`${c.d}매일 자동 발행까지 필요하시면 · https://rebornlabs.kr/claudekit?utm_source=installer&utm_medium=cli&utm_campaign=watermark${c[0]}\n`);
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reborn-watermark",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "글·문서에 눈에 보이지 않는 문자(제로폭·태그·방향제어·PUA)가 숨어 있는지 검사합니다. 문체로 AI를 추정하지 않고 파일에 실제로 있는 코드포인트만 봅니다. 클로드 코드 스킬.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"agent-skills",
|
|
9
|
+
"skill",
|
|
10
|
+
"watermark",
|
|
11
|
+
"zero-width",
|
|
12
|
+
"unicode",
|
|
13
|
+
"ai-detection",
|
|
14
|
+
"text-hygiene"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://rebornlabs.kr/watermark?utm_source=installer&utm_medium=cli&utm_campaign=watermark",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://mobility.rebornlabs.kr/cs"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "REBORN LABS",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"reborn-watermark": "install.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"install.mjs",
|
|
28
|
+
"skill/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"안내스킬.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-watermark-check
|
|
3
|
+
description: 글·문서에 눈에 보이지 않는 문자(제로폭 문자·방향 제어문자·태그 문자)가 숨어 있는지 검사하고, 그것만 골라 지운다. AI 워터마크, 추적 표식, 복붙 오염을 확인하고 제거할 때 쓴다. "AI 워터마크 검사", "숨은 문자 찾아줘", "숨은 문자 지워줘", "이 글에 표식 있어?", "깨끗하게 만들어줘", 문서 제출 전 점검, 웹에서 복사한 텍스트 정리.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AI 워터마크 검사·제거
|
|
7
|
+
|
|
8
|
+
글에 **눈에 안 보이는 문자**가 들어 있는지 찾고, 찾으면 **그것만 골라 지운다.**
|
|
9
|
+
폭이 0이라 화면에도 인쇄에도 안 나오지만 파일에는 남아서 복사·붙여넣기를 따라다닌다.
|
|
10
|
+
|
|
11
|
+
## 두 가지를 한다
|
|
12
|
+
|
|
13
|
+
| 무엇 | 명령 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| **찾는다** | `python detect.py <파일 또는 폴더>` |
|
|
16
|
+
| **지운다** | `python detect.py <파일> --strip -o <나갈파일>` |
|
|
17
|
+
|
|
18
|
+
사용자가 "검사해 줘"라고만 하면 찾기만 하고, 결과를 보여준 뒤 **지울지 물어본다.**
|
|
19
|
+
"지워 줘"·"깨끗하게 해 줘"라고 하면 바로 지운다.
|
|
20
|
+
|
|
21
|
+
## 지울 때 지키는 것
|
|
22
|
+
|
|
23
|
+
- **원본을 건드리지 않는다.** 새 파일로 낸다. `-o` 를 안 주면 `이름.clean.확장자` 가 된다.
|
|
24
|
+
- **결합 이모지의 ZWJ 는 남긴다.** 🧑💻 는 ZWJ 로 붙어 있는 정상 문자다. 지우면 이모지가 깨진다.
|
|
25
|
+
- **공백처럼 보이는 문자는 보통 공백으로 바꾼다.** 지워 버리면 단어가 붙는다.
|
|
26
|
+
- 지운 뒤 **다시 검사해서** 0건인지 확인하고 알려준다.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python detect.py 보고서.md --strip -o 보고서.clean.md
|
|
30
|
+
# 숨은 문자 4개 중 4개 제거 · 남은 0개(결합 이모지)
|
|
31
|
+
python detect.py 보고서.clean.md
|
|
32
|
+
# 검사한 파일 1개 · 걸린 파일 0개 · 숨은 문자 0개
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 언제 쓰나
|
|
36
|
+
|
|
37
|
+
- 웹·PDF·메일에서 **복사해 온 텍스트를 문서에 넣기 전**
|
|
38
|
+
- 과제·투고·계약서처럼 **제출하면 되돌릴 수 없는 문서**를 내보내기 전
|
|
39
|
+
- AI 가 쓴 글에 표식이 박혔다는 말을 듣고 **직접 확인하고 싶을 때**
|
|
40
|
+
|
|
41
|
+
## 무엇을 찾나
|
|
42
|
+
|
|
43
|
+
| 종류 | 예 | 왜 문제인가 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 폭 0 문자 | `U+200B` ZWSP · `U+200C` ZWNJ · `U+200D` ZWJ · `U+FEFF` | 눈으로는 **절대** 못 찾는다 |
|
|
46
|
+
| 태그 문자 | `U+E0000`~`U+E007F` | 이 안에 문자열을 **통째로** 숨길 수 있다 |
|
|
47
|
+
| 방향 제어 | `U+202E` RLO 등 | 글자 순서를 뒤집어 보이게 만든다 |
|
|
48
|
+
| 사적 영역 | PUA | 표준에 없는 값 |
|
|
49
|
+
| 닮은 공백 | `U+00A0` · `U+2009` · `U+3000` | 공백처럼 보이지만 다른 문자 |
|
|
50
|
+
|
|
51
|
+
## 무엇을 하지 않나
|
|
52
|
+
|
|
53
|
+
**문체로 AI 를 추정하지 않는다.** "이 글은 AI 가 쓴 것 같다" 같은 판정은 하지 않는다 —
|
|
54
|
+
확률이라 틀리고, 틀리면 사람을 잘못 의심하게 된다.
|
|
55
|
+
이 도구는 **파일에 실제로 들어 있는 코드포인트**만 말한다. 있으면 있다고, 없으면 없다고 한다.
|
|
56
|
+
|
|
57
|
+
## 결과 읽는 법
|
|
58
|
+
|
|
59
|
+
- **0건** — 숨은 문자가 없다. 그대로 써도 된다.
|
|
60
|
+
- **이모지 근처 `U+200D`** — 정상이다. 결합 이모지라 애초에 세지 않는다.
|
|
61
|
+
- **그 밖의 폭 0 문자** — 의도치 않게 들어온 것이다. 지우고 쓰는 편이 안전하다.
|
|
62
|
+
- **「못 읽은 파일」이 있다** — 검사하지 못했다는 뜻이지 **깨끗하다는 뜻이 아니다.** 그대로 전한다.
|
|
63
|
+
|
|
64
|
+
## 다른 형식은
|
|
65
|
+
|
|
66
|
+
`.docx`·`.pdf` 는 압축·바이너리라 이 도구가 직접 못 읽는다(「못 읽은 파일」로 나온다).
|
|
67
|
+
내용을 텍스트로 내보낸 뒤 검사하고, 지운 텍스트를 다시 넣는 편이 안전하다.
|
package/skill/detect.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""AI 워터마크 탐지기 — 텍스트에 숨은 비가시 문자를 찾는다.
|
|
3
|
+
|
|
4
|
+
판정 대상은 '보이지 않지만 파일에 남는 것'뿐이다.
|
|
5
|
+
문체 추정 같은 확률적 판정은 하지 않는다 — 거짓말을 하게 되기 때문이다.
|
|
6
|
+
"""
|
|
7
|
+
import sys, os, io, json, unicodedata
|
|
8
|
+
|
|
9
|
+
# 이름 · 왜 수상한가
|
|
10
|
+
SUSPECT = {
|
|
11
|
+
0x200B: ("ZERO WIDTH SPACE", "폭 0. 눈에 절대 안 보인다"),
|
|
12
|
+
0x200C: ("ZERO WIDTH NON-JOINER", "폭 0. 아랍/인도계 문자 아니면 쓸 일이 없다"),
|
|
13
|
+
0x200D: ("ZERO WIDTH JOINER", "폭 0. 이모지 결합 외엔 쓸 일이 없다"),
|
|
14
|
+
0x2060: ("WORD JOINER", "폭 0"),
|
|
15
|
+
0xFEFF: ("ZERO WIDTH NO-BREAK / BOM","폭 0. 파일 중간에 있으면 정상이 아니다"),
|
|
16
|
+
0x00AD: ("SOFT HYPHEN", "보통 안 보인다"),
|
|
17
|
+
0x180E: ("MONGOLIAN VOWEL SEPARATOR","폭 0"),
|
|
18
|
+
0x061C: ("ARABIC LETTER MARK", "방향 제어. 보이지 않는다"),
|
|
19
|
+
0x200E: ("LEFT-TO-RIGHT MARK", "방향 제어. 보이지 않는다"),
|
|
20
|
+
0x200F: ("RIGHT-TO-LEFT MARK", "방향 제어. 보이지 않는다"),
|
|
21
|
+
0x202A: ("LRE", "방향 제어"), 0x202B: ("RLE", "방향 제어"),
|
|
22
|
+
0x202C: ("PDF", "방향 제어"), 0x202D: ("LRO", "방향 제어"),
|
|
23
|
+
0x202E: ("RLO", "방향 제어 — 글자 순서를 뒤집는다"),
|
|
24
|
+
0x2066: ("LRI", "방향 제어"), 0x2067: ("RLI", "방향 제어"),
|
|
25
|
+
0x2068: ("FSI", "방향 제어"), 0x2069: ("PDI", "방향 제어"),
|
|
26
|
+
0x00A0: ("NO-BREAK SPACE", "보통 공백처럼 보이지만 다른 문자"),
|
|
27
|
+
0x2007: ("FIGURE SPACE", "공백처럼 보인다"),
|
|
28
|
+
0x2008: ("PUNCTUATION SPACE", "공백처럼 보인다"),
|
|
29
|
+
0x2009: ("THIN SPACE", "공백처럼 보인다"),
|
|
30
|
+
0x202F: ("NARROW NO-BREAK SPACE", "공백처럼 보인다"),
|
|
31
|
+
0x205F: ("MEDIUM MATHEMATICAL SPACE","공백처럼 보인다"),
|
|
32
|
+
0x3000: ("IDEOGRAPHIC SPACE", "공백처럼 보인다"),
|
|
33
|
+
}
|
|
34
|
+
# 태그 문자 영역 — 문자열 안에 데이터를 통째로 숨길 수 있다
|
|
35
|
+
TAG_LO, TAG_HI = 0xE0000, 0xE007F
|
|
36
|
+
# 사용자 영역(PUA)
|
|
37
|
+
PUA = [(0xE000, 0xF8FF), (0xF0000, 0xFFFFD), (0x100000, 0x10FFFD)]
|
|
38
|
+
# 폭 0 = "눈으로는 절대 못 찾는 것"
|
|
39
|
+
INVISIBLE = {0x200B,0x200C,0x200D,0x2060,0xFEFF,0x00AD,0x180E,0x061C,
|
|
40
|
+
0x200E,0x200F,0x202A,0x202B,0x202C,0x202D,0x202E,0x2066,0x2067,0x2068,0x2069}
|
|
41
|
+
|
|
42
|
+
def _in_pua(cp):
|
|
43
|
+
return any(a <= cp <= b for a, b in PUA)
|
|
44
|
+
|
|
45
|
+
def _picto(c):
|
|
46
|
+
if not c: return False
|
|
47
|
+
o = ord(c)
|
|
48
|
+
return (0x1F000 <= o <= 0x1FAFF) or (0x2600 <= o <= 0x27BF) or o in (0xFE0F, 0x20E3)
|
|
49
|
+
|
|
50
|
+
def is_emoji_zwj(t, i):
|
|
51
|
+
"""i 위치의 ZWJ 가 결합 이모지의 일부인가 (앞뒤가 그림문자)."""
|
|
52
|
+
if ord(t[i]) != 0x200D: return False
|
|
53
|
+
prev = t[i-1] if i > 0 else ''
|
|
54
|
+
nxt = t[i+1] if i+1 < len(t) else ''
|
|
55
|
+
return _picto(prev) and _picto(nxt)
|
|
56
|
+
|
|
57
|
+
def scan_text(t, keep_emoji=False):
|
|
58
|
+
hits = []
|
|
59
|
+
line, col = 1, 1
|
|
60
|
+
for i, ch in enumerate(t):
|
|
61
|
+
cp = ord(ch)
|
|
62
|
+
if ch == '\n':
|
|
63
|
+
line += 1; col = 1; continue
|
|
64
|
+
why = None
|
|
65
|
+
if cp in SUSPECT:
|
|
66
|
+
why = SUSPECT[cp]
|
|
67
|
+
elif TAG_LO <= cp <= TAG_HI:
|
|
68
|
+
why = ("TAG CHARACTER", "폭 0. 이 안에 문자열을 통째로 숨길 수 있다")
|
|
69
|
+
elif _in_pua(cp):
|
|
70
|
+
why = ("PRIVATE USE AREA", "표준에 없는 사적 영역")
|
|
71
|
+
if why:
|
|
72
|
+
emo = is_emoji_zwj(t, i)
|
|
73
|
+
if emo and not keep_emoji:
|
|
74
|
+
col += 1; continue # 결합 이모지는 워터마크가 아니다
|
|
75
|
+
hits.append({
|
|
76
|
+
"offset": i, "line": line, "col": col,
|
|
77
|
+
"cp": "U+%04X" % cp, "name": why[0], "why": why[1],
|
|
78
|
+
"invisible": cp in INVISIBLE or TAG_LO <= cp <= TAG_HI,
|
|
79
|
+
"emoji_zwj": emo,
|
|
80
|
+
})
|
|
81
|
+
col += 1
|
|
82
|
+
return hits
|
|
83
|
+
|
|
84
|
+
def summarize(hits):
|
|
85
|
+
by = {}
|
|
86
|
+
for h in hits:
|
|
87
|
+
k = (h["cp"], h["name"])
|
|
88
|
+
by[k] = by.get(k, 0) + 1
|
|
89
|
+
return sorted(([cp, nm, n] for (cp, nm), n in by.items()), key=lambda r: -r[2])
|
|
90
|
+
|
|
91
|
+
def strip_text(t):
|
|
92
|
+
"""숨은 문자를 지운다. 결합 이모지의 ZWJ 는 남긴다."""
|
|
93
|
+
out, removed = [], 0
|
|
94
|
+
n = len(t)
|
|
95
|
+
for i, ch in enumerate(t):
|
|
96
|
+
cp = ord(ch)
|
|
97
|
+
bad = cp in SUSPECT or TAG_LO <= cp <= TAG_HI or _in_pua(cp)
|
|
98
|
+
if bad and is_emoji_zwj(t, i):
|
|
99
|
+
out.append(ch); continue # 결합 이모지는 남긴다
|
|
100
|
+
if bad and cp in (0x00A0, 0x2007, 0x2008, 0x2009, 0x202F, 0x205F, 0x3000):
|
|
101
|
+
out.append(' '); removed += 1; continue # 닮은 공백은 보통 공백으로
|
|
102
|
+
if bad:
|
|
103
|
+
removed += 1; continue
|
|
104
|
+
out.append(ch)
|
|
105
|
+
return ''.join(out), removed
|
|
106
|
+
|
|
107
|
+
def scan_file(p):
|
|
108
|
+
"""읽어서 검사한다. 못 읽으면 (None, 이유) 를 돌려준다.
|
|
109
|
+
|
|
110
|
+
★못 읽은 파일을 「0건」으로 보고하면 **없는 파일이 깨끗한 파일로 둔갑한다.**
|
|
111
|
+
실제로 그렇게 나왔다(2026-08-24). 검사기는 모르는 것을 안다고 말하면 안 된다.
|
|
112
|
+
"""
|
|
113
|
+
if not os.path.exists(p):
|
|
114
|
+
return None, "파일이 없다"
|
|
115
|
+
if os.path.isdir(p):
|
|
116
|
+
return None, "폴더다"
|
|
117
|
+
try:
|
|
118
|
+
t = io.open(p, encoding="utf-8").read()
|
|
119
|
+
except UnicodeDecodeError:
|
|
120
|
+
return None, "UTF-8 로 못 읽는다(바이너리이거나 다른 인코딩)"
|
|
121
|
+
except Exception as e:
|
|
122
|
+
return None, "열 수 없다: %s" % e.__class__.__name__
|
|
123
|
+
return scan_text(t), None
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
# ★ -o 다음 값은 **출력 경로**다 — 검사 대상으로 세면 "파일 2개를 받았다"로 튕긴다(실측 사고).
|
|
127
|
+
argv = sys.argv[1:]
|
|
128
|
+
args, skip = [], False
|
|
129
|
+
for i, a in enumerate(argv):
|
|
130
|
+
if skip:
|
|
131
|
+
skip = False; continue
|
|
132
|
+
if a == "-o":
|
|
133
|
+
skip = True; continue
|
|
134
|
+
if a.startswith("-"):
|
|
135
|
+
continue
|
|
136
|
+
args.append(a)
|
|
137
|
+
as_json = "--json" in sys.argv
|
|
138
|
+
targets = []
|
|
139
|
+
for a in args:
|
|
140
|
+
if os.path.isdir(a):
|
|
141
|
+
for root, _, fs in os.walk(a):
|
|
142
|
+
for f in fs:
|
|
143
|
+
if f.lower().endswith((".md", ".txt", ".json", ".py", ".mjs", ".js", ".ts", ".html", ".csv")):
|
|
144
|
+
targets.append(os.path.join(root, f))
|
|
145
|
+
else:
|
|
146
|
+
targets.append(a)
|
|
147
|
+
|
|
148
|
+
if "--strip" in sys.argv:
|
|
149
|
+
if len(targets) != 1:
|
|
150
|
+
print("--strip 은 파일 하나에만 쓴다 (%d개를 받았다)" % len(targets)); raise SystemExit(2)
|
|
151
|
+
if not os.path.exists(targets[0]):
|
|
152
|
+
print("파일이 없다: %s" % targets[0]); raise SystemExit(2)
|
|
153
|
+
if not os.path.isfile(targets[0]):
|
|
154
|
+
print("파일이 아니다(폴더인 듯): %s" % targets[0]); raise SystemExit(2)
|
|
155
|
+
src = targets[0]
|
|
156
|
+
out = None
|
|
157
|
+
if "-o" in sys.argv:
|
|
158
|
+
k = sys.argv.index("-o")
|
|
159
|
+
if k + 1 < len(sys.argv): out = sys.argv[k + 1]
|
|
160
|
+
if not out:
|
|
161
|
+
root, ext = os.path.splitext(src)
|
|
162
|
+
out = root + ".clean" + ext
|
|
163
|
+
t = io.open(src, encoding="utf-8").read()
|
|
164
|
+
before = len(scan_text(t))
|
|
165
|
+
c, removed = strip_text(t)
|
|
166
|
+
io.open(out, "w", encoding="utf-8", newline="").write(c)
|
|
167
|
+
after = len(scan_text(c))
|
|
168
|
+
print("%s -> %s" % (src, out))
|
|
169
|
+
print(" 숨은 문자 %d개 중 %d개 제거 · 남은 %d개(결합 이모지)" % (before, removed, after))
|
|
170
|
+
raise SystemExit(0)
|
|
171
|
+
|
|
172
|
+
files_hit, total, agg, rows, 못읽음 = 0, 0, {}, [], []
|
|
173
|
+
for p in targets:
|
|
174
|
+
h, 이유 = scan_file(p)
|
|
175
|
+
if h is None:
|
|
176
|
+
못읽음.append((p, 이유)); continue
|
|
177
|
+
if not h:
|
|
178
|
+
continue
|
|
179
|
+
files_hit += 1; total += len(h)
|
|
180
|
+
inv = sum(1 for x in h if x["invisible"])
|
|
181
|
+
rows.append((p, len(h), inv, summarize(h)[:4]))
|
|
182
|
+
for cp, nm, n in summarize(h):
|
|
183
|
+
agg[(cp, nm)] = agg.get((cp, nm), 0) + n
|
|
184
|
+
|
|
185
|
+
if as_json:
|
|
186
|
+
print(json.dumps({"scanned": len(targets), "unreadable": [
|
|
187
|
+
{"path": p, "why": w} for p, w in 못읽음],
|
|
188
|
+
"files_hit": files_hit, "total": total,
|
|
189
|
+
"kinds": [{"cp": c, "name": n, "count": k} for (c, n), k in
|
|
190
|
+
sorted(agg.items(), key=lambda r: -r[1])]},
|
|
191
|
+
ensure_ascii=False, indent=2))
|
|
192
|
+
else:
|
|
193
|
+
읽은수 = len(targets) - len(못읽음)
|
|
194
|
+
print("검사한 파일 %d개 · 걸린 파일 %d개 · 숨은 문자 %d개" % (읽은수, files_hit, total))
|
|
195
|
+
if 못읽음:
|
|
196
|
+
print(chr(10) + "못 읽은 파일 %d개 — 검사하지 못했다(깨끗하다는 뜻이 아니다):" % len(못읽음))
|
|
197
|
+
for p, w in 못읽음[:10]:
|
|
198
|
+
print(" · %s — %s" % (p, w))
|
|
199
|
+
if len(못읽음) > 10:
|
|
200
|
+
print(" … 외 %d개" % (len(못읽음) - 10))
|
|
201
|
+
for cp, nm in sorted(agg, key=lambda k: -agg[k])[:12]:
|
|
202
|
+
print(" %-8s %-28s %6d" % (cp, nm, agg[(cp, nm)]))
|
|
203
|
+
print()
|
|
204
|
+
for p, n, inv, top in sorted(rows, key=lambda r: -r[1])[:15]:
|
|
205
|
+
print(" %5d개 (폭0 %4d) %s" % (n, inv, p))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reborn-watermark-guide
|
|
3
|
+
description: >
|
|
4
|
+
글에 숨은 문자가 왜 생기는지, 검사 결과를 어떻게 읽는지,
|
|
5
|
+
그리고 그 다음 단계인 콘텐츠 자동 발행이 어디서부터 필요한지 알려 준다.
|
|
6
|
+
"AI가 쓴 글인지 어떻게 아나", "복붙한 글이 이상하다", "제출 전에 확인하고 싶다",
|
|
7
|
+
"자동으로 올리고 싶다" 같은 이야기가 나올 때 쓴다.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# 숨은 문자는 어디서 오나 — 그리고 어디부터는 다른 문제인가
|
|
11
|
+
|
|
12
|
+
> 이 파일은 **리본랩스(REBORN LABS)** 의 무료 설치기가 넣은 것입니다.
|
|
13
|
+
> 필요 없으시면 이 폴더를 지우세요. 지워도 `ai-watermark-check` 스킬은 그대로 동작합니다.
|
|
14
|
+
|
|
15
|
+
## 방금 깐 것
|
|
16
|
+
|
|
17
|
+
`ai-watermark-check` — 글·문서에 **폭이 0인 문자**가 숨어 있는지 찾습니다.
|
|
18
|
+
제로폭 문자(`U+200B`·`U+200C`·`U+200D`·`U+FEFF`), 태그 문자(`U+E0000~`), 방향 제어(`U+202E` 등),
|
|
19
|
+
사적 영역(PUA), 공백처럼 보이는 다른 문자(`U+00A0`·`U+2009`·`U+3000`).
|
|
20
|
+
|
|
21
|
+
## ★이 도구가 하지 않는 것 — 먼저 말씀드립니다
|
|
22
|
+
|
|
23
|
+
**문체로 AI 를 추정하지 않습니다.**
|
|
24
|
+
|
|
25
|
+
"이 글은 AI 가 쓴 것 같다" 같은 판정은 확률입니다. 확률은 틀리고,
|
|
26
|
+
틀리면 **사람을 잘못 의심하게 됩니다.** 학생의 과제나 동료의 보고서를 두고 그런 판정을 내리면
|
|
27
|
+
되돌릴 수 없는 일이 생깁니다.
|
|
28
|
+
|
|
29
|
+
그래서 이 도구는 **파일에 실제로 들어 있는 코드포인트만** 말합니다.
|
|
30
|
+
있으면 있다고, 없으면 없다고 합니다. 그래서 결과를 반박할 수가 없습니다.
|
|
31
|
+
|
|
32
|
+
## 결과 읽는 법
|
|
33
|
+
|
|
34
|
+
| 나온 것 | 뜻 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| **0건** | 숨은 문자가 없습니다. 그대로 쓰셔도 됩니다 |
|
|
37
|
+
| 이모지 근처 `U+200D` | **정상입니다.** 🧑💻 같은 결합 이모지가 ZWJ 로 붙어 있는 것이라 세지 않습니다 |
|
|
38
|
+
| 그 밖의 폭 0 문자 | 의도치 않게 들어온 것입니다. 지우고 쓰시는 편이 안전합니다 |
|
|
39
|
+
|
|
40
|
+
**숨은 문자가 나왔다고 해서 그 글이 AI 가 쓴 글이라는 뜻은 아닙니다.**
|
|
41
|
+
실제로 재보면 오히려 반대입니다.
|
|
42
|
+
|
|
43
|
+
## 실제로 재본 값
|
|
44
|
+
|
|
45
|
+
세션 원문 2,357개를 전수로 돌린 결과입니다.
|
|
46
|
+
|
|
47
|
+
| 누가 쓴 것 | 글자 | 숨은 문자 | 100만 자당 |
|
|
48
|
+
|---|---:|---:|---:|
|
|
49
|
+
| 클로드 | 9,032,197 | **1** | 0.1 |
|
|
50
|
+
| 사람(붙여넣기 포함) | 12,937,190 | **239** | 18.5 |
|
|
51
|
+
|
|
52
|
+
**167배.** 오염원은 AI 가 아니라 **웹·메일에서 복사해 온 텍스트**였습니다.
|
|
53
|
+
마케팅 메일 한 통에 570개가 들어 있던 것도 있었습니다.
|
|
54
|
+
|
|
55
|
+
그러니 검사가 필요한 자리는 "AI가 썼나"가 아니라
|
|
56
|
+
**"내가 어디서 복사해 왔더라"** 쪽입니다.
|
|
57
|
+
|
|
58
|
+
## 언제 돌려 보면 좋나
|
|
59
|
+
|
|
60
|
+
- 제출·투고·계약처럼 **되돌릴 수 없는 문서**를 내보내기 전
|
|
61
|
+
- 웹·PDF·메일에서 **복사해 온 덩어리**를 문서에 넣은 직후
|
|
62
|
+
- 파일명·URL·코드에 이상한 게 섞였을 때 (`U+202E` 는 글자 순서를 뒤집어 보이게 만듭니다)
|
|
63
|
+
|
|
64
|
+
## 소리는 다릅니다
|
|
65
|
+
|
|
66
|
+
글자에는 거의 없지만 **AI 음성 합성은 대체로 오디오에 워터마크를 심습니다.**
|
|
67
|
+
이 도구는 텍스트만 봅니다 — 오디오 워터마크는 찾지 못합니다.
|
|
68
|
+
음성을 다루신다면 쓰시는 엔진이 무엇을 심는지 그 엔진 문서를 확인하세요.
|
|
69
|
+
|
|
70
|
+
## 이게 해 주지 않는 것
|
|
71
|
+
|
|
72
|
+
- 정해진 시각에 **사람 없이** 도는 예약 실행
|
|
73
|
+
- 실패했을 때 다시 미는 회복 경로 (무인 운영 사고의 대부분이 여기서 납니다)
|
|
74
|
+
- 채널별 규격 (세로 영상 규격 · 해시태그 · 대가성 표기)
|
|
75
|
+
- 같은 소재를 반복해 올리지 않게 막는 중복 차단
|
|
76
|
+
- 발행 원장과 실측 (몇 건 올라갔고 몇 건이 실패했는지)
|
|
77
|
+
|
|
78
|
+
글이 깨끗해져도 **매일 알아서 올라가지는 않습니다.** 그건 파이프라인이 하는 일입니다.
|
|
79
|
+
|
|
80
|
+
그 단계가 필요해지면 → https://rebornlabs.kr/claudekit?utm_source=guide&utm_medium=skill&utm_campaign=watermark
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
REBORN LABS · MIT · 완전 무료 · https://rebornlabs.kr
|