Skip to content

Commit 69e1210

Browse files
committed
Fix FlexTV chat capture
1 parent 4e09152 commit 69e1210

3 files changed

Lines changed: 295 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"test:aiprompt:adversarial": "node scripts/aiprompt-ssnpatch-adversarial-e2e.cjs",
99
"test:aiprompt:conversation": "node scripts/playwright-aiprompt-conversation-e2e.cjs",
1010
"test:aiprompt:expectations": "node scripts/playwright-aiprompt-expectations-e2e.cjs",
11+
"test:flextv": "node tests/flextv-source.test.js",
1112
"local-tts-bridge": "node scripts/local-tts-bridge.cjs",
1213
"r2:large-assets:plan": "node scripts/sync-large-assets-r2.cjs",
1314
"r2:large-assets:verify": "node scripts/sync-large-assets-r2.cjs --verify",

sources/flextv.js

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@
8989
} else {
9090
if (node.nodeName === "IMG" && node.src) {
9191
node.src = node.src + "";
92+
resp += node.outerHTML || "";
93+
} else if ((node.textContent || "").trim().length) {
94+
resp += node.outerHTML || "";
9295
}
93-
resp += node.outerHTML || "";
9496
}
9597
}
9698
});
@@ -180,6 +182,17 @@
180182
return meta;
181183
}
182184

185+
function findNameElement(item) {
186+
var nameEle = item.querySelector(".chat-user-name [data-member]");
187+
if (!nameEle) {
188+
nameEle = item.querySelector(".chat-user-name .inline-block");
189+
}
190+
if (!nameEle) {
191+
nameEle = item.querySelector(".chat-user-name");
192+
}
193+
return nameEle;
194+
}
195+
183196
function rememberMessage(signature) {
184197
var now = Date.now();
185198
recentlySeenMessages.set(signature, now);
@@ -197,6 +210,13 @@
197210
return !!previousSeenAt && Date.now() - previousSeenAt < DUPLICATE_WINDOW_MS;
198211
}
199212

213+
function getMessageSignature(data) {
214+
if (!data) {
215+
return "";
216+
}
217+
return (data.userid || "") + "::" + data.chatname + "::" + data.chatmessage;
218+
}
219+
200220
function markProcessed(item, signature) {
201221
if (!item || !item.dataset) {
202222
return;
@@ -208,7 +228,7 @@
208228
}
209229

210230
function buildMessageData(item) {
211-
var nameEle = item.querySelector(".chat-user-name [data-member], .chat-user-name .inline-block, .chat-user-name");
231+
var nameEle = findNameElement(item);
212232
var nameWrapper = item.querySelector(".chat-user-name");
213233
var member = parseMember(nameEle);
214234
var name = "";
@@ -262,17 +282,19 @@
262282
if (!item || item.nodeType !== 1 || !item.querySelector) {
263283
return;
264284
}
265-
if (!force && item.dataset && item.dataset.ssnProcessed === "1") {
266-
return;
267-
}
268285

269286
var data = buildMessageData(item);
270287
if (!data) {
271288
markProcessed(item);
272289
return;
273290
}
274291

275-
var signature = data.userid + "::" + data.chatname + "::" + data.chatmessage;
292+
var signature = getMessageSignature(data);
293+
if (!force && item.dataset && item.dataset.ssnProcessed === "1") {
294+
if (item.dataset.ssnLastMessageSignature === signature) {
295+
return;
296+
}
297+
}
276298
if (item.dataset && item.dataset.ssnLastMessageSignature === signature) {
277299
markProcessed(item, signature);
278300
return;
@@ -287,6 +309,24 @@
287309
sendToApp({ message: data });
288310
}
289311

312+
function findMessageNode(node) {
313+
if (!node) {
314+
return null;
315+
}
316+
if (node.nodeType === 1) {
317+
if (node.matches && node.matches(CHAT_ITEM_SELECTOR)) {
318+
return node;
319+
}
320+
if (node.closest) {
321+
return node.closest(CHAT_ITEM_SELECTOR);
322+
}
323+
}
324+
if (node.parentElement && node.parentElement.closest) {
325+
return node.parentElement.closest(CHAT_ITEM_SELECTOR);
326+
}
327+
return null;
328+
}
329+
290330
function forEachMessageNode(node, callback) {
291331
if (!node || node.nodeType !== 1) {
292332
return;
@@ -304,10 +344,13 @@
304344
function markExistingMessages(container) {
305345
try {
306346
container.querySelectorAll(CHAT_ITEM_SELECTOR).forEach(function (item) {
307-
markProcessed(item);
308347
var data = buildMessageData(item);
309348
if (data) {
310-
rememberMessage(data.userid + "::" + data.chatname + "::" + data.chatmessage);
349+
var signature = getMessageSignature(data);
350+
rememberMessage(signature);
351+
markProcessed(item, signature);
352+
} else {
353+
markProcessed(item);
311354
}
312355
});
313356
} catch (e) {}
@@ -345,12 +388,20 @@
345388
forEachMessageNode(mutation.addedNodes[i], processMessage);
346389
} catch (e) {}
347390
}
391+
} else if (mutation.type === "characterData") {
392+
try {
393+
var item = findMessageNode(mutation.target);
394+
if (item) {
395+
processMessage(item);
396+
}
397+
} catch (e) {}
348398
}
349399
});
350400
});
351401

352402
observer.observe(target, {
353403
childList: true,
404+
characterData: true,
354405
subtree: true
355406
});
356407
}

tests/flextv-source.test.js

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
const assert = require("assert");
2+
const fs = require("fs");
3+
const path = require("path");
4+
const { chromium } = require("playwright");
5+
6+
const root = path.resolve(__dirname, "..");
7+
const source = fs.readFileSync(path.join(root, "sources", "flextv.js"), "utf8");
8+
const soakIterations = Number(process.env.FLEXTV_SOAK_ITERATIONS || 30);
9+
10+
function waitForMessageCount(page, expected, timeout = 7000) {
11+
return page.waitForFunction(
12+
(count) => window.__flextvMessages && window.__flextvMessages.length === count,
13+
expected,
14+
{ timeout }
15+
);
16+
}
17+
18+
(async () => {
19+
const browser = await chromium.launch({ headless: true });
20+
const page = await browser.newPage();
21+
22+
await page.setContent(`
23+
<!doctype html>
24+
<html>
25+
<body>
26+
<div class="chat-content relative!">
27+
<div class="chat-list overscroll-none touch-scroll-y" style="display: flex; flex-direction: column-reverse;">
28+
<div id="chat-feed" style="display: block; z-index: 1; zoom: 1;">
29+
<div id="message-root"></div>
30+
</div>
31+
</div>
32+
</div>
33+
</body>
34+
</html>
35+
`);
36+
37+
await page.addScriptTag({
38+
content: `
39+
window.__flextvMessages = [];
40+
window.__memberId = 970221;
41+
window.chrome = {
42+
runtime: {
43+
id: "test-extension",
44+
lastError: null,
45+
sendMessage: function (id, payload, callback) {
46+
if (payload && payload.getSettings) {
47+
if (callback) {
48+
callback({ settings: { textonlymode: false } });
49+
}
50+
return;
51+
}
52+
if (payload && payload.message) {
53+
window.__flextvMessages.push(payload.message);
54+
}
55+
if (callback) {
56+
callback({});
57+
}
58+
},
59+
onMessage: {
60+
addListener: function () {}
61+
}
62+
}
63+
};
64+
65+
window.__addFlexMessage = function (id, name, messageHtml, options) {
66+
options = options || {};
67+
var member = {
68+
id: options.userId || window.__memberId++,
69+
channelId: options.channelId || 708210,
70+
loginId: options.loginId || "sh5712",
71+
nickname: name,
72+
gender: "M",
73+
level: options.level || 0,
74+
month: 0,
75+
role: { app: "V", channel: "V" },
76+
isIdentify: true,
77+
ranking: typeof options.ranking === "number" ? options.ranking : -1,
78+
isDumb: false
79+
};
80+
81+
var wrapper = document.createElement("div");
82+
var item = document.createElement("div");
83+
item.className = "chat-item";
84+
item.id = id;
85+
86+
var bg = document.createElement("div");
87+
bg.className = "bg";
88+
bg.style.paddingLeft = "0px";
89+
90+
var emojiWrap = document.createElement("div");
91+
emojiWrap.className = "emoji-wrap ";
92+
93+
var txtWrap = document.createElement("div");
94+
txtWrap.className = "txt-wrap";
95+
txtWrap.style.marginBottom = "0px";
96+
97+
var header = document.createElement("div");
98+
header.className = "flex items-center gap-[6px]";
99+
100+
var badge = document.createElement("img");
101+
badge.className = "h-[20px] sm:h-[24px] object-contain";
102+
badge.src = options.badge || "https://static.flextv.co.kr/20240802/ico_user_normal_81685e8c4b.png";
103+
header.appendChild(badge);
104+
105+
var nameWrapper = document.createElement("span");
106+
nameWrapper.className = "chat-user-name cursor-pointer flex items-center";
107+
nameWrapper.style.color = options.nameColor || "rgb(0, 0, 0)";
108+
nameWrapper.style.textShadow = "none";
109+
110+
var nameSpan = document.createElement("span");
111+
nameSpan.className = "inline-block";
112+
nameSpan.dataset.member = JSON.stringify(member);
113+
nameSpan.textContent = name;
114+
nameWrapper.appendChild(nameSpan);
115+
116+
var gender = document.createElement("img");
117+
gender.alt = "gender";
118+
gender.className = "w-[8px] h-[10px] object-contain ml-1";
119+
gender.src = "/svg/ico_man.svg";
120+
nameWrapper.appendChild(gender);
121+
header.appendChild(nameWrapper);
122+
123+
var body = document.createElement("div");
124+
body.className = "flex flex-col";
125+
var chat = document.createElement("div");
126+
chat.className = "chat ml-[30px]!";
127+
chat.style.color = "rgb(0, 0, 0)";
128+
chat.style.textShadow = "none";
129+
var chatInner = document.createElement("div");
130+
chatInner.innerHTML = messageHtml;
131+
chat.appendChild(chatInner);
132+
body.appendChild(chat);
133+
134+
txtWrap.appendChild(header);
135+
txtWrap.appendChild(body);
136+
bg.appendChild(emojiWrap);
137+
bg.appendChild(txtWrap);
138+
item.appendChild(bg);
139+
wrapper.appendChild(item);
140+
document.getElementById("message-root").appendChild(wrapper);
141+
return id;
142+
};
143+
144+
window.__updateFlexMessageText = function (id, message) {
145+
var span = document.querySelector("#" + id + " .chat span");
146+
if (!span) {
147+
return false;
148+
}
149+
if (span.firstChild) {
150+
span.firstChild.nodeValue = message;
151+
} else {
152+
span.textContent = message;
153+
}
154+
return true;
155+
};
156+
`
157+
});
158+
159+
await page.evaluate(() => {
160+
window.__addFlexMessage("backlog-1", "\uD604\uC774:)", "<span>\uC774\uC804</span>", { userId: 970221 });
161+
});
162+
163+
await page.addScriptTag({ content: source });
164+
await page.waitForFunction(() => document.querySelector("#backlog-1")?.dataset.ssnLastMessageSignature);
165+
assert.strictEqual(await page.evaluate(() => window.__flextvMessages.length), 0, "initial backlog should not send");
166+
167+
await page.evaluate(() => {
168+
window.__addFlexMessage("pending-1", "\uB290\uB9B0\uC0AC\uC6A9\uC790", "<span></span>", { userId: 970223 });
169+
});
170+
await page.waitForTimeout(1300);
171+
assert.strictEqual(await page.evaluate(() => window.__flextvMessages.length), 0, "empty pending row should not send");
172+
173+
await page.evaluate(() => window.__updateFlexMessageText("pending-1", "\uB2A6\uAC8C \uB4E4\uC5B4\uC628 \uCC44\uD305"));
174+
await waitForMessageCount(page, 1);
175+
assert.strictEqual(await page.evaluate(() => window.__flextvMessages[0].chatmessage), "\uB2A6\uAC8C \uB4E4\uC5B4\uC628 \uCC44\uD305");
176+
177+
await page.evaluate(() => {
178+
window.__addFlexMessage("live-1", "\uD604\uC774:)", "<span>\u3145\u3145\u3145</span>", {
179+
userId: 970222,
180+
badge: "https://static.flextv.co.kr/fanIcon/202507/fd6d7f343c6eed67.png",
181+
nameColor: "rgb(255, 2, 74)",
182+
ranking: 2
183+
});
184+
});
185+
await waitForMessageCount(page, 2);
186+
187+
const first = await page.evaluate(() => window.__flextvMessages[1]);
188+
assert.strictEqual(first.type, "flextv");
189+
assert.strictEqual(first.chatname, "\uD604\uC774:)");
190+
assert.strictEqual(first.chatmessage, "\u3145\u3145\u3145");
191+
assert.strictEqual(first.userid, "970222", JSON.stringify(first));
192+
assert.strictEqual(first.meta.userId, 970222);
193+
assert.strictEqual(first.meta.ranking, 2);
194+
assert.deepStrictEqual(first.chatbadges, ["https://static.flextv.co.kr/fanIcon/202507/fd6d7f343c6eed67.png"]);
195+
assert.ok(first.nameColor.indexOf("255") !== -1, "name color should be preserved");
196+
197+
await page.waitForTimeout(1300);
198+
assert.strictEqual(await page.evaluate(() => window.__flextvMessages.length), 2, "idle scan should not duplicate");
199+
200+
await page.evaluate(() => window.__updateFlexMessageText("live-1", "\uBCC0\uACBD\uB41C \uCC44\uD305"));
201+
await waitForMessageCount(page, 3);
202+
assert.strictEqual(await page.evaluate(() => window.__flextvMessages[2].chatmessage), "\uBCC0\uACBD\uB41C \uCC44\uD305");
203+
204+
await page.evaluate(async (count) => {
205+
for (var i = 0; i < count; i++) {
206+
window.__addFlexMessage("soak-" + i, "soak-user-" + i, "<span>soak message " + i + "</span>", {
207+
userId: 980000 + i
208+
});
209+
await new Promise((resolve) => setTimeout(resolve, 3));
210+
}
211+
}, soakIterations);
212+
await waitForMessageCount(page, 3 + soakIterations, 15000);
213+
214+
const reuseIterations = Math.max(5, Math.floor(soakIterations / 5));
215+
await page.evaluate(async (count) => {
216+
for (var i = 0; i < count; i++) {
217+
window.__updateFlexMessageText("live-1", "reuse message " + i);
218+
await new Promise((resolve) => setTimeout(resolve, 20));
219+
}
220+
}, reuseIterations);
221+
await waitForMessageCount(page, 3 + soakIterations + reuseIterations, 15000);
222+
223+
await page.waitForTimeout(1700);
224+
assert.strictEqual(
225+
await page.evaluate(() => window.__flextvMessages.length),
226+
3 + soakIterations + reuseIterations,
227+
"post-soak idle scan should not duplicate"
228+
);
229+
230+
await browser.close();
231+
console.log(`FLEX TV source passed (${soakIterations} appended, ${reuseIterations} reused).`);
232+
})().catch(async (error) => {
233+
console.error(error);
234+
process.exit(1);
235+
});

0 commit comments

Comments
 (0)