feat: 抖音直播间弹幕监控 - Go+Wails 实现
后端: - SQLite 数据层 (rooms/users/chat_logs/settings/error_logs) - 抖音 WebSocket 抓取器 (proto 解析, 心跳, watchdog) - __ac_signature 算法 (纯 Go 移植) - a_bogus/sign.js 签名 (goja JS 引擎) - Echo HTTP API 服务器 (端口 5000) - SSE 事件广播 - 自动保活 + 授权守护 - PAYS 云授权对接 前端: - 顶栏: 品牌 + 日志/调试/客户名单/授权/文档/房间控制/主题切换 - 添加直播间 (链接解析 + 关键词) - Dashboard (卡片/列表视图, 公开/匿名房筛选, 分页) - 详情页 (多 tab 消息流, 右侧用户面板, 颜色标记, 精准客户, 已看标记) - 精准客户名单 (跨房间汇总) - 订阅授权页面 (登录/注册/购买) - 使用帮助 + 系统调试 - 白昼/黑夜主题切换 (CSS 变量 + iconfont) - 底部版权栏
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Build output
|
||||||
|
build/bin/
|
||||||
|
dist/
|
||||||
|
*.exe
|
||||||
|
!build/windows/icon.ico
|
||||||
|
!assets/icon.png
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
data.db
|
||||||
|
live_fetcher.log
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
7
app.go
Normal file
7
app.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type App struct{ ctx context.Context }
|
||||||
|
func NewApp() *App { return &App{} }
|
||||||
|
func (a *App) Startup(ctx context.Context) { a.ctx = ctx }
|
||||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
389
assets/sign/a_bogus.js
Normal file
389
assets/sign/a_bogus.js
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
function get_ab(dpf, ua) {
|
||||||
|
function enc_sum(n_str) {
|
||||||
|
function ir(t) {
|
||||||
|
return ir = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (t) {
|
||||||
|
return typeof t
|
||||||
|
} : function (t) {
|
||||||
|
return t && "function" == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype ? "symbol" : typeof t
|
||||||
|
},
|
||||||
|
ir(t)
|
||||||
|
}
|
||||||
|
function ur(t, r) {
|
||||||
|
for (var e = 0; e < r.length; e++) {
|
||||||
|
var n = r[e];
|
||||||
|
n.enumerable = n.enumerable || !1,
|
||||||
|
n.configurable = !0, "value" in n && (n.writable = !0),
|
||||||
|
Object.defineProperty(t, sr(n.key), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sr(t) {
|
||||||
|
var r = function (t, r) {
|
||||||
|
if ("object" != ir(t) || !t) return t;
|
||||||
|
var e = t[Symbol.toPrimitive];
|
||||||
|
if (void 0 !== e) {
|
||||||
|
var n = e.call(t, r || "default");
|
||||||
|
if ("object" != ir(n)) return n;
|
||||||
|
throw new TypeError("@@toPrimitive must return a primitive value.")
|
||||||
|
}
|
||||||
|
return ("string" === r ? String : Number)(t)
|
||||||
|
}(t, "string");
|
||||||
|
return "symbol" == ir(r) ? r : r + ""
|
||||||
|
}
|
||||||
|
var gr = function () {
|
||||||
|
function t() {
|
||||||
|
if (function (t, r) {
|
||||||
|
if (!(t instanceof r)) throw new TypeError("Cannot call a class as a function")
|
||||||
|
}(this, t), !(this instanceof t)) return new t;
|
||||||
|
this.reg = new Array(8),
|
||||||
|
this.chunk = [],
|
||||||
|
this.size = 0,
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
return function (t, r, e) {
|
||||||
|
r && ur(t.prototype, r),
|
||||||
|
e && ur(t, e),
|
||||||
|
Object.defineProperty(t, "prototype", {
|
||||||
|
writable: !1
|
||||||
|
})
|
||||||
|
}(t, [{
|
||||||
|
key: "reset",
|
||||||
|
value: function () {
|
||||||
|
this.reg[0] = 1937774191,
|
||||||
|
this.reg[1] = 1226093241,
|
||||||
|
this.reg[2] = 388252375,
|
||||||
|
this.reg[3] = 3666478592,
|
||||||
|
this.reg[4] = 2842636476,
|
||||||
|
this.reg[5] = 372324522,
|
||||||
|
this.reg[6] = 3817729613,
|
||||||
|
this.reg[7] = 2969243214,
|
||||||
|
this.chunk = [],
|
||||||
|
this.size = 0
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "write",
|
||||||
|
value: function (t) {
|
||||||
|
var r = "string" == typeof t ? function (t) {
|
||||||
|
var r = encodeURIComponent(t).replace(/%([0-9A-F]{2})/g, (function (t, r) {
|
||||||
|
return String.fromCharCode("0x" + r)
|
||||||
|
})),
|
||||||
|
e = new Array(r.length);
|
||||||
|
return Array.prototype.forEach.call(r, (function (t, r) {
|
||||||
|
e[r] = t.charCodeAt(0)
|
||||||
|
})),
|
||||||
|
e
|
||||||
|
}(t) : t;
|
||||||
|
this.size += r.length;
|
||||||
|
var e = 64 - this.chunk.length;
|
||||||
|
if (r.length < e) this.chunk = this.chunk.concat(r);
|
||||||
|
else
|
||||||
|
for (this.chunk = this.chunk.concat(r.slice(0, e)); this.chunk.length >= 64;) this._compress(this.chunk),
|
||||||
|
e < r.length ? this.chunk = r.slice(e, Math.min(e + 64, r.length)) : this.chunk = [],
|
||||||
|
e += 64
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "sum",
|
||||||
|
value: function (t, r) {
|
||||||
|
t && (this.reset(), this.write(t)),
|
||||||
|
this._fill();
|
||||||
|
for (var e = 0; e < this.chunk.length; e += 64) this._compress(this.chunk.slice(e, e + 64));
|
||||||
|
var n, o, i, u = null;
|
||||||
|
if ("hex" == r) {
|
||||||
|
u = "";
|
||||||
|
for (e = 0; e < 8; e++) u += (n = this.reg[e].toString(16), o = 8, i = "0", n.length >= o ? n : i.repeat(o - n.length) + n)
|
||||||
|
} else
|
||||||
|
for (u = new Array(32), e = 0; e < 8; e++) {
|
||||||
|
var s = this.reg[e];
|
||||||
|
u[4 * e + 3] = (255 & s) >>> 0,
|
||||||
|
s >>>= 8,
|
||||||
|
u[4 * e + 2] = (255 & s) >>> 0,
|
||||||
|
s >>>= 8,
|
||||||
|
u[4 * e + 1] = (255 & s) >>> 0,
|
||||||
|
s >>>= 8,
|
||||||
|
u[4 * e] = (255 & s) >>> 0
|
||||||
|
}
|
||||||
|
return this.reset(),
|
||||||
|
u
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "_compress",
|
||||||
|
value: function (t) {
|
||||||
|
if (t < 64) console.error("compress error: not enough data");
|
||||||
|
else {
|
||||||
|
for (var r = function (t) {
|
||||||
|
for (var r = new Array(132), e = 0; e < 16; e++) r[e] = t[4 * e] << 24,
|
||||||
|
r[e] |= t[4 * e + 1] << 16,
|
||||||
|
r[e] |= t[4 * e + 2] << 8,
|
||||||
|
r[e] |= t[4 * e + 3],
|
||||||
|
r[e] >>>= 0;
|
||||||
|
for (var n = 16; n < 68; n++) {
|
||||||
|
var o = r[n - 16] ^ r[n - 9] ^ dr(r[n - 3], 15);
|
||||||
|
o = o ^ dr(o, 15) ^ dr(o, 23),
|
||||||
|
r[n] = (o ^ dr(r[n - 13], 7) ^ r[n - 6]) >>> 0
|
||||||
|
}
|
||||||
|
for (n = 0; n < 64; n++) r[n + 68] = (r[n] ^ r[n + 4]) >>> 0;
|
||||||
|
return r
|
||||||
|
}(t), e = this.reg.slice(0), n = 0; n < 64; n++) {
|
||||||
|
var o = dr(e[0], 12) + e[4] + dr(yr(n), n),
|
||||||
|
i = ((o = dr(o = (4294967295 & o) >>> 0, 7)) ^ dr(e[0], 12)) >>> 0,
|
||||||
|
u = br(n, e[0], e[1], e[2]);
|
||||||
|
u = (4294967295 & (u = u + e[3] + i + r[n + 68])) >>> 0;
|
||||||
|
var s = mr(n, e[4], e[5], e[6]);
|
||||||
|
s = (4294967295 & (s = s + e[7] + o + r[n])) >>> 0,
|
||||||
|
e[3] = e[2],
|
||||||
|
e[2] = dr(e[1], 9),
|
||||||
|
e[1] = e[0],
|
||||||
|
e[0] = u,
|
||||||
|
e[7] = e[6],
|
||||||
|
e[6] = dr(e[5], 19),
|
||||||
|
e[5] = e[4],
|
||||||
|
e[4] = (s ^ dr(s, 9) ^ dr(s, 17)) >>> 0
|
||||||
|
}
|
||||||
|
for (var c = 0; c < 8; c++) this.reg[c] = (this.reg[c] ^ e[c]) >>> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "_fill",
|
||||||
|
value: function () {
|
||||||
|
var t = 8 * this.size,
|
||||||
|
r = this.chunk.push(128) % 64;
|
||||||
|
for (64 - r < 8 && (r -= 64); r < 56; r++) this.chunk.push(0);
|
||||||
|
for (var e = 0; e < 4; e++) {
|
||||||
|
var n = Math.floor(t / 4294967296);
|
||||||
|
this.chunk.push(n >>> 8 * (3 - e) & 255)
|
||||||
|
}
|
||||||
|
for (e = 0; e < 4; e++) this.chunk.push(t >>> 8 * (3 - e) & 255)
|
||||||
|
}
|
||||||
|
}]),
|
||||||
|
t
|
||||||
|
}();
|
||||||
|
function dr(t, r) {
|
||||||
|
return (t << (r %= 32) | t >>> 32 - r) >>> 0
|
||||||
|
}
|
||||||
|
function yr(t) {
|
||||||
|
return 0 <= t && t < 16 ? 2043430169 : 16 <= t && t < 64 ? 2055708042 : void console.error("invalid j for constant Tj")
|
||||||
|
}
|
||||||
|
function br(t, r, e, n) {
|
||||||
|
return 0 <= t && t < 16 ? (r ^ e ^ n) >>> 0 : 16 <= t && t < 64 ? (r & e | r & n | e & n) >>> 0 : (console.error("invalid j for bool function FF"), 0)
|
||||||
|
}
|
||||||
|
function mr(t, r, e, n) {
|
||||||
|
return 0 <= t && t < 16 ? (r ^ e ^ n) >>> 0 : 16 <= t && t < 64 ? (r & e | ~r & n) >>> 0 : (console.error("invalid j for bool function GG"), 0)
|
||||||
|
}
|
||||||
|
enc_ = new gr;
|
||||||
|
return enc_.sum(n_str);
|
||||||
|
}
|
||||||
|
function generate_lm_g_EP(ua_n = ua) {
|
||||||
|
// function get_sz256f_2() {
|
||||||
|
// var r = [], k = 0, y = [0, 1, 0];
|
||||||
|
// for (var i = 255; i >= 0; i--) {
|
||||||
|
// r.push(i);
|
||||||
|
// }
|
||||||
|
// for (var i = 0; i < r.length; i++) {
|
||||||
|
// var a = r[i];
|
||||||
|
// k = (k * a + k + y[i % 3]) % 256;
|
||||||
|
// var b = r[k];
|
||||||
|
// r[i] = b, r[k] = a;
|
||||||
|
// }
|
||||||
|
// return r;
|
||||||
|
// }
|
||||||
|
var sz256f_2 = [233, 5, 1, 249, 162, 140, 57, 143, 19, 203, 254, 236, 99, 248, 93, 213, 79, 149, 216, 50, 145, 123, 240, 92, 23, 113, 130, 53, 235, 220, 201, 136, 223, 155, 190, 242, 243, 42, 52, 214, 151, 232, 97, 187, 163, 222, 30, 78, 47, 71, 49, 170, 247, 196, 25, 156, 183, 182, 217, 180, 147, 124, 208, 69, 215, 200, 161, 154, 91, 60, 133, 224, 119, 164, 221, 45, 98, 40, 186, 120, 51, 167, 38, 90, 194, 212, 129, 56, 87, 195, 144, 44, 75, 84, 81, 13, 197, 245, 36, 250, 115, 100, 105, 252, 206, 103, 112, 202, 114, 138, 192, 21, 116, 173, 181, 29, 82, 125, 141, 16, 211, 131, 225, 118, 31, 101, 77, 146, 135, 150, 62, 66, 67, 176, 0, 41, 46, 59, 107, 178, 43, 26, 189, 128, 8, 207, 166, 110, 3, 229, 85, 54, 63, 11, 32, 4, 234, 142, 72, 58, 33, 231, 12, 230, 102, 86, 70, 159, 226, 65, 237, 34, 244, 76, 132, 122, 111, 95, 179, 152, 175, 18, 177, 6, 126, 193, 219, 74, 134, 2, 61, 251, 191, 168, 209, 241, 137, 165, 88, 238, 160, 174, 153, 157, 199, 48, 22, 64, 246, 7, 139, 55, 27, 188, 148, 204, 127, 171, 89, 37, 172, 205, 121, 20, 28, 17, 169, 15, 227, 117, 80, 218, 198, 10, 106, 9, 39, 210, 104, 83, 109, 24, 108, 228, 184, 96, 185, 158, 14, 255, 239, 68, 94, 35, 73, 253];
|
||||||
|
var k = 0, s = '';
|
||||||
|
for (var i = 0; i < ua_n.length; i++) {
|
||||||
|
var i1 = (i + 1) % 256;
|
||||||
|
var a = sz256f_2[i1];
|
||||||
|
k = (k + a) % 256;
|
||||||
|
var c = sz256f_2[k];
|
||||||
|
sz256f_2[i1] = c;
|
||||||
|
sz256f_2[k] = a;
|
||||||
|
s += String.fromCharCode(ua_n.charCodeAt(i) ^ sz256f_2[(a + c) % 256]);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_str_chr_list(one_str) {
|
||||||
|
var r = []; // 当然也可以用map实现
|
||||||
|
for (var i = 0; i < one_str.length; i++) {
|
||||||
|
r.push(one_str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function generate_szenc_head8p1() {
|
||||||
|
var z = Math.random() * 65535;
|
||||||
|
var a = z & 255;
|
||||||
|
var b = (z >> 8) & 255, d = [];
|
||||||
|
d.push((a & 170) | 1);
|
||||||
|
d.push((a & 85) | 0);
|
||||||
|
d.push((b & 170) | 0);
|
||||||
|
d.push((b & 85) | 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function generate_szenc_head8p2() {
|
||||||
|
var a = ((Math.random() * 240) >> 0) + 1;
|
||||||
|
var b = ((Math.random() * 255) >> 0) & 77, c = [1, 4, 5, 7], d = [];
|
||||||
|
for (var i = 0; i < c.length; i++) {
|
||||||
|
b = b | (1 << c[i]);
|
||||||
|
}
|
||||||
|
d.push((a & 170) | 1);
|
||||||
|
d.push((a & 85) | 0);
|
||||||
|
d.push((b & 170) | 0);
|
||||||
|
d.push((b & 85) | 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function get_szenc_tail(sz96_n) {
|
||||||
|
key_sz_6 = [145, 110, 66, 189, 44, 211]
|
||||||
|
var a = [];
|
||||||
|
for (var i = 0; i < 94; i += 3) {
|
||||||
|
var b = sz96_n[i];
|
||||||
|
var c = sz96_n[i + 1];
|
||||||
|
var d = sz96_n[i + 2];
|
||||||
|
var e = (Math.random() * 1000) & 255;
|
||||||
|
a.push((e & key_sz_6[0]) | (b & key_sz_6[1]));
|
||||||
|
a.push((e & key_sz_6[2]) | (c & key_sz_6[3]));
|
||||||
|
a.push((e & key_sz_6[4]) | (d & key_sz_6[5]));
|
||||||
|
a.push(((b & key_sz_6[0]) | (c & key_sz_6[2])) | (d & key_sz_6[4]));
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
function generate_lm_g_ab_head4() {
|
||||||
|
var s = '';
|
||||||
|
var a = (Math.random() * 65535) & 255, b = (Math.random() * 40) >> 0;
|
||||||
|
s += String.fromCharCode((a & 170) | 1);
|
||||||
|
s += String.fromCharCode((a & 85) | 2);
|
||||||
|
s += String.fromCharCode((b & 170) | 80);
|
||||||
|
s += String.fromCharCode((b & 85) | 2);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_list_str(one_list) {
|
||||||
|
var s = '';
|
||||||
|
for (var i = 0; i < one_list.length; i++) {
|
||||||
|
s += String.fromCharCode(one_list[i]);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_lm_g_ab(lm_g_lm_n) {
|
||||||
|
// function get_sz256() {
|
||||||
|
// var raw = [];
|
||||||
|
// var z = 0;
|
||||||
|
// for (var i = 255; i >= 0; i--) {
|
||||||
|
// raw.push(i);
|
||||||
|
// }
|
||||||
|
// for (var i = 0; i < raw.length; i++) {
|
||||||
|
// z += 211;
|
||||||
|
// var a = z % 256;
|
||||||
|
// var b = raw[i];
|
||||||
|
// var c = raw[a];
|
||||||
|
// raw[a] = b;
|
||||||
|
// raw[i] = c;
|
||||||
|
// z = (raw[i + 1] * a) + a;
|
||||||
|
// }
|
||||||
|
// return raw;
|
||||||
|
// }
|
||||||
|
var fixed_sz256_n = [194, 249, 255, 165, 114, 67, 251, 187, 174, 231, 164, 237, 124, 235, 68, 83, 206, 79, 142, 167, 30, 77, 0, 93, 118, 29, 32, 161, 2, 171, 243, 179, 42, 170, 223, 119, 98, 222, 219, 57, 245, 135, 197, 13, 186, 202, 88, 184, 214, 12, 76, 185, 116, 74, 54, 53, 104, 208, 158, 163, 82, 173, 253, 240, 172, 63, 191, 207, 25, 15, 201, 203, 215, 236, 183, 233, 145, 127, 72, 6, 16, 10, 228, 35, 232, 159, 66, 168, 108, 71, 217, 75, 33, 155, 112, 128, 36, 24, 138, 50, 211, 23, 107, 14, 247, 137, 175, 242, 234, 157, 199, 49, 139, 85, 81, 17, 180, 86, 120, 78, 51, 205, 169, 148, 181, 3, 94, 106, 252, 220, 150, 47, 151, 84, 212, 18, 149, 182, 100, 123, 121, 156, 154, 152, 126, 204, 60, 133, 132, 248, 7, 91, 58, 59, 20, 97, 113, 117, 131, 46, 250, 224, 21, 73, 146, 31, 193, 69, 140, 125, 9, 39, 89, 5, 65, 141, 218, 80, 1, 70, 64, 166, 87, 189, 55, 147, 22, 26, 143, 61, 144, 99, 92, 44, 129, 130, 227, 103, 90, 192, 198, 244, 136, 101, 246, 153, 56, 38, 4, 178, 221, 162, 134, 37, 111, 28, 216, 96, 102, 210, 254, 196, 195, 230, 241, 62, 11, 122, 52, 40, 41, 229, 226, 225, 48, 45, 160, 105, 8, 115, 34, 43, 209, 95, 239, 190, 188, 109, 27, 19, 176, 213, 200, 238, 177, 110];
|
||||||
|
// var fixed_sz256_n = get_sz256();
|
||||||
|
var z = 0;
|
||||||
|
var st = "";
|
||||||
|
for (var i = 0; i < lm_g_lm_n.length; i++) {
|
||||||
|
var a = (i + 1) % 256;
|
||||||
|
var c = fixed_sz256_n[a];
|
||||||
|
z = (z + c) % 256;
|
||||||
|
var e = fixed_sz256_n[z];
|
||||||
|
fixed_sz256_n[a] = e;
|
||||||
|
fixed_sz256_n[z] = c;
|
||||||
|
var g = (e + c) % 256;
|
||||||
|
var h = lm_g_lm_n.charCodeAt(i);
|
||||||
|
var j = fixed_sz256_n[g];
|
||||||
|
var k = h ^ j;
|
||||||
|
var l = String.fromCharCode(k);
|
||||||
|
st += l;
|
||||||
|
}
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
function get_raw_ab(lm_get_ab_n, key_str = info_dic.s4) {
|
||||||
|
var s = "", bw = 0;
|
||||||
|
for (var i = 0; i < lm_get_ab_n.length; i += 3) {
|
||||||
|
var cl = 16;
|
||||||
|
var tcz = 0;
|
||||||
|
var sof = 16515072;
|
||||||
|
for (var j = i; j < i + 3; j++) {
|
||||||
|
if (j < lm_get_ab_n.length) {
|
||||||
|
var tlcy = lm_get_ab_n.charCodeAt(j) & 255;
|
||||||
|
tcz = tcz | (tlcy << cl);
|
||||||
|
cl -= 8;
|
||||||
|
} else {
|
||||||
|
bw += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var h = 18; h >= (6 * bw); h -= 6) {
|
||||||
|
var tsz = tcz & sof;
|
||||||
|
s += key_str[tsz >> h];
|
||||||
|
sof = sof / 64;
|
||||||
|
}
|
||||||
|
s += '='.repeat(bw);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_random_number(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
var info_dic = { "s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
|
||||||
|
"s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
|
||||||
|
"s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
|
||||||
|
"s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
|
||||||
|
"s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe" }
|
||||||
|
var t1 = Date.now();
|
||||||
|
var s = [];
|
||||||
|
|
||||||
|
var t2 = Date.now() - 1 + get_random_number(1, 3);
|
||||||
|
|
||||||
|
var EP = get_raw_ab(generate_lm_g_EP(ua), key_str = info_dic.s3), eEP = enc_sum(EP);
|
||||||
|
|
||||||
|
s.push("env_fx_list", "dpf_ua_dic", 1, 0, 8, "dpf", "", "ua", 6241, 6383, "1.0.1.19-fix.01", "ink", 3, "0X21_dic"); // 固定即可
|
||||||
|
|
||||||
|
var t3 = Date.now() + get_random_number(4, 15);
|
||||||
|
var eedp = enc_sum(enc_sum(dpf + 'dhzx'));
|
||||||
|
|
||||||
|
s.push(t3, "reg_dic", 1, 0, eedp, "eedh", EP, eEP, t2, [3, 82], 41, [1, 0, 1, 0, 1]);
|
||||||
|
|
||||||
|
var t4 = Date.now() + get_random_number(100, 1000);
|
||||||
|
|
||||||
|
s1 = ((t4 - 1721836800000) / 1000 / 60 / 60 / 24 / 14) >> 0, szenc_o95_tail41 = [49, 52, 52, 49, 124, 56, 51, 56, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 54, 49, 124, 87, 105, 110, 51, 50];
|
||||||
|
|
||||||
|
s.push(s1, 6, (t3 - t1 + 3) & 255, t3 & 255, (t3 >> 8) & 255, (t3 >> 16) & 255, (t3 >> 24) & 255, (t3 / 256 / 256 / 256 / 256) & 255);
|
||||||
|
|
||||||
|
var s2 = (t3 / 256 / 256 / 256 / 256 / 256) & 255;
|
||||||
|
|
||||||
|
s.push(s2, (s2 % 256) & 255, (s2 / 256) & 255, [211, 2, 5, 1, 129], 129, 0, 211, 2, 5, 1, 0, 0, 0, 0, eedp[9], eedp[18], 3, eedp[3], 82, 177, 4, 44, eEP[11], eEP[21], 5, eEP[5], t2 & 255, (t2 >> 8) & 255, (t2 >> 16) & 255, (t2 >> 24) & 255, (t2 / 256 / 256 / 256 / 256) & 255, (t2 / 256 / 256 / 256 / 256 / 256) & 255, 3, 97, 24, 0, 0, 239, 24, 0, 0, "screec_dic", "screen_str", szenc_o95_tail41, 41, 41, 0);
|
||||||
|
|
||||||
|
var s3 = ((t3 + 3) & 255) + ',', s4 = get_str_chr_list(s3);
|
||||||
|
|
||||||
|
s.push(s3, s4, s4.length, s4.length & 255, (s4.length >> 8) & 255);
|
||||||
|
|
||||||
|
szenc_head8_p1 = generate_szenc_head8p1(), szenc_head8_p2 = generate_szenc_head8p2(), szenc_head8 = szenc_head8_p1.concat(szenc_head8_p2), s5 = [], s6 = [24, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 55, 56, 57, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 84, 85];
|
||||||
|
for (var i = 0; i < s6.length; i++) {
|
||||||
|
s5.push(s[s6[i]]);
|
||||||
|
}
|
||||||
|
s.push(szenc_head8);
|
||||||
|
var s7 = szenc_head8.concat(s5);
|
||||||
|
|
||||||
|
var s8 = s7[0];
|
||||||
|
for (var i = 1; i < s7.length; i++) {
|
||||||
|
s8 = s8 ^ s7[i];
|
||||||
|
}
|
||||||
|
s.push(s8);
|
||||||
|
|
||||||
|
enc_s_i = [34, 44, 56, 61, 73, 29, 70, 45, 35, 49, 38, 66, 51, 68, 28, 48, 64, 47, 30, 71, 26, 55, 31, 69, 59, 40, 62, 63, 27, 72, 41, 74, 57, 52, 42, 39, 33, 67, 53, 43, 65, 46, 36, 24, 60, 32, 79, 80, 84, 85];
|
||||||
|
szenc_o95_head50 = [];
|
||||||
|
for (var i = 0; i < enc_s_i.length; i++) {
|
||||||
|
szenc_o95_head50.push(s[enc_s_i[i]]);
|
||||||
|
}
|
||||||
|
szenc_o95 = [];
|
||||||
|
szenc_o95 = szenc_o95.concat(szenc_o95_head50, szenc_o95_tail41, s4, [s8]);
|
||||||
|
|
||||||
|
szenc_tail = get_szenc_tail(szenc_o95);
|
||||||
|
szenc = szenc_head8.concat(szenc_tail);
|
||||||
|
|
||||||
|
lm_get_ab_head4 = generate_lm_g_ab_head4();
|
||||||
|
|
||||||
|
var lm_get_lm = get_list_str(szenc);
|
||||||
|
var lm_get_ab_tail = get_lm_g_ab(lm_get_lm);
|
||||||
|
var lm_get_ab = lm_get_ab_head4 + lm_get_ab_tail
|
||||||
|
return get_raw_ab(lm_get_ab);
|
||||||
|
}
|
||||||
7011
assets/sign/sign.js
Normal file
7011
assets/sign/sign.js
Normal file
File diff suppressed because one or more lines are too long
BIN
build/appicon.png
Normal file
BIN
build/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
build/windows/icon.ico
Normal file
BIN
build/windows/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
4
build/windows/info.json
Normal file
4
build/windows/info.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"fixed": { "file_version": "{{.Info.ProductVersion}}" },
|
||||||
|
"info": { "0000": { "ProductVersion": "{{.Info.ProductVersion}}", "CompanyName": "{{.Info.CompanyName}}", "FileDescription": "{{.Info.ProductName}}", "LegalCopyright": "{{.Info.Copyright}}", "ProductName": "{{.Info.ProductName}}", "Comments": "" } }
|
||||||
|
}
|
||||||
15
build/windows/wails.exe.manifest
Normal file
15
build/windows/wails.exe.manifest
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<asmv3:application>
|
||||||
|
<asmv3:windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||||
|
</asmv3:windowsSettings>
|
||||||
|
</asmv3:application>
|
||||||
|
</assembly>
|
||||||
6606
douyin.proto
Normal file
6606
douyin.proto
Normal file
File diff suppressed because it is too large
Load Diff
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>抖音直播间需求抓取</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1919
frontend/package-lock.json
generated
Normal file
1919
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"qrcode.react": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/package.json.md5
Normal file
1
frontend/package.json.md5
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2e6f182c838e63c2b9afa8bd486d948a
|
||||||
1500
frontend/src/App.css
Normal file
1500
frontend/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
305
frontend/src/App.tsx
Normal file
305
frontend/src/App.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { errorReporter } from './errorReporter'
|
||||||
|
import Header from './components/Header'
|
||||||
|
import Dashboard from './components/Dashboard'
|
||||||
|
import DetailView from './components/DetailView'
|
||||||
|
import LeadView from './components/LeadView'
|
||||||
|
import LicenseView from './components/LicenseView'
|
||||||
|
import DocView from './components/DocView'
|
||||||
|
import DebugView from './components/DebugView'
|
||||||
|
import { ICONS } from './constants'
|
||||||
|
import { useLs } from './utils'
|
||||||
|
import type { RoomData, SysState, LicenseState, Page, RoomMessage } from './types'
|
||||||
|
|
||||||
|
errorReporter()
|
||||||
|
|
||||||
|
const API = (path: string, init?: RequestInit) => fetch(`/api${path}`, init)
|
||||||
|
|
||||||
|
function compactIcon(label: string, active: boolean, click: () => void) {
|
||||||
|
return (
|
||||||
|
<button className={`topbar-btn compact ${active ? 'active' : ''}`} onClick={click} title={label}>
|
||||||
|
{ICONS.room}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [theme, setTheme] = useLs<'light'|'dark'>('theme', 'dark')
|
||||||
|
const [rooms, setRooms] = useState<Record<string, RoomData>>({})
|
||||||
|
const [activeRoom, setActiveRoom] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState<Page>('home')
|
||||||
|
const [sys, setSys] = useState<SysState>({ version: '', rooms_total: 0, running: 0, error_count: 0, uptime: 0 })
|
||||||
|
const [license, setLicense] = useState<LicenseState | null>(null)
|
||||||
|
const [focusUser, setFocusUser] = useState<string | null>(null)
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false)
|
||||||
|
const [debugMode, setDebugMode] = useLs('debugMode', false)
|
||||||
|
const [viewMode, setViewMode] = useLs<'card'|'list'>('viewMode', 'card')
|
||||||
|
const [cardCols, setCardCols] = useLs('cardCols', 3)
|
||||||
|
const [listPerPage, setListPerPage] = useLs('listPerPage', 20)
|
||||||
|
const [filterKind, setFilterKind] = useState<'all'|'public'|'anon'>('all')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const msgIntervals = useRef<Record<string, ReturnType<typeof setInterval>>>({})
|
||||||
|
|
||||||
|
const needAuth = license && (license.state === 'unconfigured' || license.state === 'expired')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme
|
||||||
|
if (theme === 'dark') {
|
||||||
|
;(window as any).WindowSetDarkTheme?.()
|
||||||
|
} else {
|
||||||
|
;(window as any).WindowSetLightTheme?.()
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
;(window as any).WindowSetDebugMode?.(debugMode)
|
||||||
|
}, [debugMode])
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource('/stream')
|
||||||
|
es.onmessage = e => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data)
|
||||||
|
if (msg.type === 'sys') setSys(msg.data)
|
||||||
|
else if (msg.type === 'room') {
|
||||||
|
setRooms(prev => ({ ...prev, [msg.data.id]: msg.data }))
|
||||||
|
if (msg.data.status === 'stopped' || msg.data.status === 'error') {
|
||||||
|
if (msgIntervals.current[msg.data.id]) {
|
||||||
|
clearInterval(msgIntervals.current[msg.data.id])
|
||||||
|
delete msgIntervals.current[msg.data.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (msg.type === 'msg') {
|
||||||
|
setRooms(prev => {
|
||||||
|
const r = prev[msg.room_id]
|
||||||
|
if (!r) return prev
|
||||||
|
const exists = r.messages.some(m => m.id === msg.data.id)
|
||||||
|
if (exists) return prev
|
||||||
|
return { ...prev, [msg.room_id]: { ...r, messages: [...r.messages, msg.data].slice(-500) } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (msg.type === 'license') setLicense(msg.data)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
es.onerror = () => es.close()
|
||||||
|
return () => es.close()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Message polling
|
||||||
|
useEffect(() => {
|
||||||
|
Object.keys(rooms).forEach(id => {
|
||||||
|
const r = rooms[id]
|
||||||
|
if (r.status === 'running' && !msgIntervals.current[id]) {
|
||||||
|
msgIntervals.current[id] = setInterval(() => {
|
||||||
|
API(`/api/rooms/${encodeURIComponent(id)}/msgs`).then(r => r.json()).then(data => {
|
||||||
|
setRooms(prev => {
|
||||||
|
const cur = prev[id]
|
||||||
|
if (!cur) return prev
|
||||||
|
const newMsgs = data.messages?.filter((m: RoomMessage) => !cur.messages.some(cm => cm.id === m.id)) || []
|
||||||
|
if (!newMsgs.length) return prev
|
||||||
|
return { ...prev, [id]: { ...cur, messages: [...cur.messages, ...newMsgs].slice(-500) } }
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
Object.values(msgIntervals.current).forEach(clearInterval)
|
||||||
|
msgIntervals.current = {}
|
||||||
|
}
|
||||||
|
}, [rooms])
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([API('/api/sys').then(r => r.json()).then(setSys), API('/api/license').then(r => r.json()).then(setLicense).catch(() => {}), API('/api/rooms').then(r => r.json()).then(setRooms)])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addRoom = useCallback(async (url: string) => {
|
||||||
|
if (needAuth) { setPage('license'); return }
|
||||||
|
try {
|
||||||
|
const res = await API('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.id) setRooms(prev => ({ ...prev, [data.id]: data }))
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}, [needAuth])
|
||||||
|
|
||||||
|
const toggleRoom = useCallback(async (id: string, action?: string) => {
|
||||||
|
if (needAuth && action === 'start') { setPage('license'); return }
|
||||||
|
try {
|
||||||
|
await API(`/api/rooms/${encodeURIComponent(id)}/${action || 'toggle'}`, { method: 'POST' })
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
}, [needAuth])
|
||||||
|
|
||||||
|
const removeRoom = useCallback(async (id: string) => {
|
||||||
|
await API(`/api/rooms/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
setRooms(prev => { const n = { ...prev }; delete n[id]; return n })
|
||||||
|
if (activeRoom === id) { setPage('home'); setActiveRoom(null) }
|
||||||
|
}, [activeRoom])
|
||||||
|
|
||||||
|
const setSeen = useCallback((roomId: string, msgId: string) => {
|
||||||
|
setRooms(prev => {
|
||||||
|
const r = prev[roomId]
|
||||||
|
if (!r) return prev
|
||||||
|
return { ...prev, [roomId]: { ...r, messages: r.messages.map(m => m.id === msgId ? { ...m, is_seen: true } : m) } }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setLead = useCallback((roomId: string, msgId: string, lead: boolean) => {
|
||||||
|
setRooms(prev => {
|
||||||
|
const r = prev[roomId]
|
||||||
|
if (!r) return prev
|
||||||
|
return { ...prev, [roomId]: { ...r, messages: r.messages.map(m => m.id === msgId ? { ...m, is_lead: lead } : m), lead_count: lead ? r.lead_count + 1 : Math.max(0, r.lead_count - 1) } }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setMark = useCallback((roomId: string, userId: string, mark: string) => {
|
||||||
|
setRooms(prev => {
|
||||||
|
const r = prev[roomId]
|
||||||
|
if (!r) return prev
|
||||||
|
return { ...prev, [roomId]: { ...r, messages: r.messages.map(m => m.user_id === userId ? { ...m, marked: mark } : m) } }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const allRooms = Object.values(rooms)
|
||||||
|
const roomList = allRooms.filter(r => {
|
||||||
|
if (filterKind === 'public' && r.kind === 'anon') return false
|
||||||
|
if (filterKind === 'anon' && r.kind === 'public') return false
|
||||||
|
if (searchQuery && !r.title.toLowerCase().includes(searchQuery.toLowerCase()) && !r.anchor_id.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSetActive = (id: string) => {
|
||||||
|
setActiveRoom(id)
|
||||||
|
setPage('detail')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<TopBar
|
||||||
|
compact={false}
|
||||||
|
theme={theme} setTheme={setTheme}
|
||||||
|
logsOpen={logsOpen} setLogsOpen={setLogsOpen}
|
||||||
|
debugMode={debugMode} setDebugMode={setDebugMode}
|
||||||
|
roomCount={allRooms.length} runningCount={sys.running}
|
||||||
|
needAuth={needAuth}
|
||||||
|
onLeads={() => setPage('leads')}
|
||||||
|
onLicense={() => setPage('license')}
|
||||||
|
onDoc={() => setPage('doc')}
|
||||||
|
onDebug={() => setPage('debug')}
|
||||||
|
onStartAll={() => allRooms.filter(r=>r.status==='stopped').forEach(r=>toggleRoom(r.id,'start'))}
|
||||||
|
onStopAll={() => allRooms.filter(r=>r.status==='running').forEach(r=>toggleRoom(r.id,'stop'))}
|
||||||
|
onUser={() => {}}
|
||||||
|
/>
|
||||||
|
<div className="app-content">
|
||||||
|
{page === 'home' && (
|
||||||
|
<Header
|
||||||
|
roomCount={roomList.length}
|
||||||
|
viewMode={viewMode} setViewMode={setViewMode}
|
||||||
|
cardCols={cardCols} setCardCols={setCardCols}
|
||||||
|
listPerPage={listPerPage} setListPerPage={setListPerPage}
|
||||||
|
filterKind={filterKind} setFilterKind={setFilterKind}
|
||||||
|
searchQuery={searchQuery} setSearchQuery={setSearchQuery}
|
||||||
|
needAuth={needAuth}
|
||||||
|
onLeads={() => setPage('leads')}
|
||||||
|
onLicense={() => setPage('license')}
|
||||||
|
onDoc={() => setPage('doc')}
|
||||||
|
onDebug={() => setPage('debug')}
|
||||||
|
onLogToggle={() => setLogsOpen(v => !v)}
|
||||||
|
onStartAll={() => allRooms.filter(r=>r.status==='stopped').forEach(r=>toggleRoom(r.id,'start'))}
|
||||||
|
onStopAll={() => allRooms.filter(r=>r.status==='running').forEach(r=>toggleRoom(r.id,'stop'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page === 'home' && (
|
||||||
|
<Dashboard
|
||||||
|
rooms={roomList}
|
||||||
|
viewMode={viewMode} cardCols={cardCols} listPerPage={listPerPage}
|
||||||
|
onAddRoom={addRoom}
|
||||||
|
onToggleRoom={toggleRoom}
|
||||||
|
onRemoveRoom={removeRoom}
|
||||||
|
onSetActive={handleSetActive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page === 'detail' && activeRoom && (
|
||||||
|
<DetailView
|
||||||
|
room={rooms[activeRoom]}
|
||||||
|
focusUser={focusUser} setFocusUser={setFocusUser}
|
||||||
|
onBack={() => setPage('home')}
|
||||||
|
onSeen={setSeen}
|
||||||
|
onLead={setLead}
|
||||||
|
onMark={setMark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page === 'leads' && <LeadView rooms={allRooms} />}
|
||||||
|
{page === 'license' && <LicenseView license={license} onBack={() => setPage('home')} />}
|
||||||
|
{page === 'doc' && <DocView />}
|
||||||
|
{page === 'debug' && <DebugView sys={sys} rooms={allRooms} />}
|
||||||
|
</div>
|
||||||
|
<div className="footer">
|
||||||
|
<span>青梧映星软件开发工作室</span>
|
||||||
|
<span>开发者: Geliebte</span>
|
||||||
|
<button className="footer-link" onClick={() => (window as any).BrowserOpenURL?.('https://yser.ging.plus/')}>客户服务中心</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
compact: boolean
|
||||||
|
theme: 'light' | 'dark'
|
||||||
|
setTheme: (v: 'light'|'dark') => void
|
||||||
|
logsOpen: boolean
|
||||||
|
setLogsOpen: (v: boolean) => void
|
||||||
|
debugMode: boolean
|
||||||
|
setDebugMode: (v: boolean) => void
|
||||||
|
roomCount: number
|
||||||
|
runningCount: number
|
||||||
|
needAuth: boolean
|
||||||
|
onLeads: () => void
|
||||||
|
onLicense: () => void
|
||||||
|
onDoc: () => void
|
||||||
|
onDebug: () => void
|
||||||
|
onStartAll: () => void
|
||||||
|
onStopAll: () => void
|
||||||
|
onUser: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar({ compact, theme, setTheme, logsOpen, debugMode, roomCount, runningCount, needAuth, onLeads, onLicense, onDoc, onDebug, onStartAll, onStopAll, onUser }: TopBarProps) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="topbar-btn" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} title="切换主题">
|
||||||
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</button>
|
||||||
|
<button className="topbar-btn" onClick={onUser} title="用户">👤</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<div className="topbar-brand">
|
||||||
|
<span className="topbar-logo">🎬</span>
|
||||||
|
<span className="topbar-title">抖音直播间需求抓取</span>
|
||||||
|
<span className="room-count-top">{roomCount} 房 / {runningCount} 运行</span>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<button className="topbar-btn" onClick={onDebug} title="调试" style={{ display: debugMode ? '' : 'none' }}>🔧 调试</button>
|
||||||
|
<button className="topbar-btn" onClick={onLeads} title="客户名单">🎯 客户</button>
|
||||||
|
<button className="topbar-btn" onClick={onLicense} title="授权" style={needAuth ? { color: '#ff4d4f' } : {}}>{needAuth ? '⚠️ 授权' : '🔑 授权'}</button>
|
||||||
|
<button className="topbar-btn" onClick={onDoc} title="文档">📖 文档</button>
|
||||||
|
{runningCount > 0
|
||||||
|
? <button className="topbar-btn topbar-stop" onClick={onStopAll}>⏹ 全部停止</button>
|
||||||
|
: roomCount > 0
|
||||||
|
? <button className="topbar-btn topbar-start" onClick={onStartAll}>▶ 全部开始</button>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<button className="topbar-btn" onClick={() => onDebug()} title="日志">📋 日志{logsOpen ? '🟢' : ''}</button>
|
||||||
|
<button className="topbar-btn" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} title="切换主题">
|
||||||
|
{theme === 'dark' ? '🌙' : '☀️'}
|
||||||
|
</button>
|
||||||
|
<button className="topbar-btn" onClick={onUser} title="用户">👤</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/fonts/fa/fa-solid-900.woff2
Normal file
BIN
frontend/src/assets/fonts/fa/fa-solid-900.woff2
Normal file
Binary file not shown.
41
frontend/src/components/AddRoomBar.tsx
Normal file
41
frontend/src/components/AddRoomBar.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import RoomTools from './RoomTools'
|
||||||
|
|
||||||
|
interface AddRoomBarProps {
|
||||||
|
onAdd: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddRoomBar({ onAdd }: AddRoomBarProps) {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('')
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) { setError('请输入直播间URL'); return }
|
||||||
|
try {
|
||||||
|
await onAdd(trimmed)
|
||||||
|
setUrl('')
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '添加失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="add-bar">
|
||||||
|
<div className="add-input-wrap">
|
||||||
|
<input
|
||||||
|
className="add-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="粘贴直播间URL或主播主页链接..."
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSubmit()}
|
||||||
|
/>
|
||||||
|
<button className="add-btn" onClick={handleSubmit}>➕ 添加</button>
|
||||||
|
</div>
|
||||||
|
{error && <span className="add-error">{error}</span>}
|
||||||
|
<RoomTools />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
frontend/src/components/Dashboard.tsx
Normal file
102
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import RoomCard from './RoomCard'
|
||||||
|
import AddRoomBar from './AddRoomBar'
|
||||||
|
import type { RoomData } from '../types'
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
rooms: RoomData[]
|
||||||
|
viewMode: 'card' | 'list'
|
||||||
|
cardCols: number
|
||||||
|
listPerPage: number
|
||||||
|
onAddRoom: (url: string) => void
|
||||||
|
onToggleRoom: (id: string, action?: string) => void
|
||||||
|
onRemoveRoom: (id: string) => void
|
||||||
|
onSetActive: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard({ rooms, viewMode, cardCols, listPerPage, onAddRoom, onToggleRoom, onRemoveRoom, onSetActive }: DashboardProps) {
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const paginatedRooms = useMemo(() => {
|
||||||
|
if (viewMode === 'list') {
|
||||||
|
const start = page * listPerPage
|
||||||
|
return rooms.slice(start, start + listPerPage)
|
||||||
|
}
|
||||||
|
return rooms
|
||||||
|
}, [rooms, page, listPerPage, viewMode])
|
||||||
|
|
||||||
|
const totalPages = viewMode === 'list' ? Math.ceil(rooms.length / listPerPage) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash-zone">
|
||||||
|
<AddRoomBar onAdd={onAddRoom} />
|
||||||
|
{viewMode === 'card' ? (
|
||||||
|
<div className="room-grid" style={{ gridTemplateColumns: `repeat(${cardCols}, 1fr)` }}>
|
||||||
|
{paginatedRooms.map(room => (
|
||||||
|
<RoomCard
|
||||||
|
key={room.id}
|
||||||
|
room={room}
|
||||||
|
onToggle={(a) => onToggleRoom(room.id, a)}
|
||||||
|
onRemove={() => onRemoveRoom(room.id)}
|
||||||
|
onDetail={() => onSetActive(room.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{paginatedRooms.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>暂无房间</p>
|
||||||
|
<p>在上方输入框添加直播间URL开始监控</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="room-list">
|
||||||
|
<table className="list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>房间</th>
|
||||||
|
<th>主播</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>观众</th>
|
||||||
|
<th>消息</th>
|
||||||
|
<th>精准</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedRooms.map(room => (
|
||||||
|
<tr key={room.id} className={`list-row ${room.status}`} onClick={() => onSetActive(room.id)}>
|
||||||
|
<td><span className={`status-dot ${room.status}`} /></td>
|
||||||
|
<td className="list-title">{room.title}</td>
|
||||||
|
<td>{room.anchor_id}</td>
|
||||||
|
<td><span className={`kind-badge ${room.kind}`}>{room.kind === 'public' ? '公开' : '匿名'}</span></td>
|
||||||
|
<td>{room.viewers}</td>
|
||||||
|
<td>{room.total_msg}</td>
|
||||||
|
<td>{room.lead_count}</td>
|
||||||
|
<td className="list-actions">
|
||||||
|
<button className="list-btn start" onClick={e => { e.stopPropagation(); onToggleRoom(room.id, 'start') }}>▶</button>
|
||||||
|
<button className="list-btn stop" onClick={e => { e.stopPropagation(); onToggleRoom(room.id, 'stop') }}>⏹</button>
|
||||||
|
<button className="list-btn del" onClick={e => { e.stopPropagation(); onRemoveRoom(room.id) }}>✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{paginatedRooms.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>暂无房间</p>
|
||||||
|
<p>在上方输入框添加直播间URL开始监控</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button className="page-btn" disabled={page === 0} onClick={() => setPage(p => p - 1)}>‹</button>
|
||||||
|
<span className="page-info">{page + 1} / {totalPages}</span>
|
||||||
|
<button className="page-btn" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>›</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
frontend/src/components/DebugView.tsx
Normal file
103
frontend/src/components/DebugView.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import type { SysState, RoomData } from '../types'
|
||||||
|
|
||||||
|
interface DebugViewProps {
|
||||||
|
sys: SysState
|
||||||
|
rooms: RoomData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DebugView({ sys, rooms }: DebugViewProps) {
|
||||||
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
|
const [wsConnected, setWsConnected] = useState(false)
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const es = new EventSource('/stream')
|
||||||
|
es.onmessage = e => {
|
||||||
|
const line = `[${new Date().toLocaleTimeString()}] ${e.data}`
|
||||||
|
setLogs(prev => [...prev.slice(-500), line])
|
||||||
|
}
|
||||||
|
es.onerror = () => setWsConnected(false)
|
||||||
|
es.onopen = () => setWsConnected(true)
|
||||||
|
return () => { es.close() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="debug-view">
|
||||||
|
<button className="back-btn" onClick={() => window.history.back()}>← 返回</button>
|
||||||
|
<h2>🔧 系统调试</h2>
|
||||||
|
|
||||||
|
<div className="debug-section">
|
||||||
|
<h3>系统状态</h3>
|
||||||
|
<div className="debug-grid">
|
||||||
|
<div className="debug-item"><span className="label">版本</span><span className="value">{sys.version || '未知'}</span></div>
|
||||||
|
<div className="debug-item"><span className="label">房间总数</span><span className="value">{sys.rooms_total}</span></div>
|
||||||
|
<div className="debug-item"><span className="label">运行中</span><span className="value running">{sys.running}</span></div>
|
||||||
|
<div className="debug-item"><span className="label">错误数</span><span className="value error">{sys.error_count}</span></div>
|
||||||
|
<div className="debug-item"><span className="label">运行时长</span><span className="value">{formatUptime(sys.uptime)}</span></div>
|
||||||
|
<div className="debug-item"><span className="label">WebSocket</span><span className={`value ${wsConnected ? 'connected' : 'disconnected'}`}>{wsConnected ? '已连接' : '未连接'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-section">
|
||||||
|
<h3>房间状态 ({rooms.length})</h3>
|
||||||
|
<div className="debug-grid">
|
||||||
|
{rooms.map(r => (
|
||||||
|
<div key={r.id} className={`debug-item room-item ${r.status}`}>
|
||||||
|
<span className="label">{r.anchor_id}</span>
|
||||||
|
<span className="value">{r.status} | 观众:{r.viewers} | 消息:{r.total_msg} | 精准:{r.lead_count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-section">
|
||||||
|
<h3>实时日志 <button className="clear-btn" onClick={() => setLogs([])}>清空</button></h3>
|
||||||
|
<div className="log-container">
|
||||||
|
{logs.length === 0 ? <div className="no-logs">暂无日志</div> : logs.map((l, i) => (
|
||||||
|
<div key={i} className="log-line">{l}</div>
|
||||||
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="debug-section">
|
||||||
|
<h3>手动测试</h3>
|
||||||
|
<div className="test-actions">
|
||||||
|
<button onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sys')
|
||||||
|
const d = await r.json()
|
||||||
|
setLogs(prev => [...prev, `[TEST] /api/sys: ${JSON.stringify(d)}`])
|
||||||
|
} catch (e) { setLogs(prev => [...prev, `[TEST] Error: ${e}`]) }
|
||||||
|
}}>测试 /api/sys</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/rooms')
|
||||||
|
const d = await r.json()
|
||||||
|
setLogs(prev => [...prev, `[TEST] /api/rooms: ${Object.keys(d).length} rooms`])
|
||||||
|
} catch (e) { setLogs(prev => [...prev, `[TEST] Error: ${e}`]) }
|
||||||
|
}}>测试 /api/rooms</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/license')
|
||||||
|
const d = await r.json()
|
||||||
|
setLogs(prev => [...prev, `[TEST] /api/license: ${JSON.stringify(d)}`])
|
||||||
|
} catch (e) { setLogs(prev => [...prev, `[TEST] Error: ${e}`]) }
|
||||||
|
}}>测试 /api/license</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
return `${h}h ${m}m ${sec}s`
|
||||||
|
}
|
||||||
193
frontend/src/components/DetailView.tsx
Normal file
193
frontend/src/components/DetailView.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
|
import { ICONS, TYPE_LABELS, MARKS, MARK_MAP } from '../constants'
|
||||||
|
import { formatTime } from '../utils'
|
||||||
|
import type { RoomData, RoomMessage } from '../types'
|
||||||
|
|
||||||
|
interface DetailViewProps {
|
||||||
|
room: RoomData | undefined
|
||||||
|
focusUser: string | null
|
||||||
|
setFocusUser: (id: string | null) => void
|
||||||
|
onBack: () => void
|
||||||
|
onSeen: (roomId: string, msgId: string) => void
|
||||||
|
onLead: (roomId: string, msgId: string, lead: boolean) => void
|
||||||
|
onMark: (roomId: string, userId: string, mark: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailView({ room, focusUser, setFocusUser, onBack, onSeen, onLead, onMark }: DetailViewProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
const [markFilter, setMarkFilter] = useState<string>('')
|
||||||
|
const [showLeadOnly, setShowLeadOnly] = useState(false)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
if (!room) return <div className="detail-empty">房间不存在</div>
|
||||||
|
|
||||||
|
const tabs = ['all', 'chat', 'gift', 'enter', 'like', 'follow', 'stats', 'fansclub', 'rank', 'status', 'lead']
|
||||||
|
|
||||||
|
const filteredMsgs = useMemo(() => {
|
||||||
|
let msgs = room.messages
|
||||||
|
if (activeTab !== 'all') {
|
||||||
|
if (activeTab === 'lead') {
|
||||||
|
msgs = msgs.filter(m => m.is_lead)
|
||||||
|
} else {
|
||||||
|
msgs = msgs.filter(m => m.type === activeTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (markFilter) {
|
||||||
|
msgs = msgs.filter(m => m.marked === markFilter)
|
||||||
|
}
|
||||||
|
if (focusUser) {
|
||||||
|
msgs = msgs.filter(m => m.user_id === focusUser)
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}, [room.messages, activeTab, markFilter, focusUser])
|
||||||
|
|
||||||
|
const userStats = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; count: number; leads: number; msgs: RoomMessage[] }>()
|
||||||
|
room.messages.forEach(m => {
|
||||||
|
const existing = map.get(m.user_id)
|
||||||
|
if (existing) {
|
||||||
|
existing.count++
|
||||||
|
if (m.is_lead) existing.leads++
|
||||||
|
} else {
|
||||||
|
map.set(m.user_id, { name: m.username, count: 1, leads: m.is_lead ? 1 : 0, msgs: [m] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.count - a.count)
|
||||||
|
}, [room.messages])
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [filteredMsgs.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-view">
|
||||||
|
<div className="detail-header">
|
||||||
|
<button className="back-btn" onClick={onBack}>← 返回</button>
|
||||||
|
<div className="detail-title">
|
||||||
|
<span className="detail-room-name">{room.title || room.anchor_id}</span>
|
||||||
|
<span className={`detail-status ${room.status}`}>{room.status === 'running' ? '● 直播中' : room.status === 'error' ? '✕ 错误' : '○ 已停止'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="detail-stats">
|
||||||
|
<span>👁 {room.viewers}</span>
|
||||||
|
<span>💬 {room.total_msg}</span>
|
||||||
|
<span>🎯 {room.lead_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-main">
|
||||||
|
<div className="detail-left">
|
||||||
|
<div className="tab-bar">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button key={tab} className={`tab-btn ${activeTab === tab ? 'active' : ''}`} onClick={() => setActiveTab(tab)}>
|
||||||
|
{ICONS[tab as keyof typeof ICONS]} {TYPE_LABELS[tab] || tab}
|
||||||
|
{tab === 'all' && <span className="tab-count">{room.messages.length}</span>}
|
||||||
|
{tab === 'lead' && <span className="tab-count">{room.messages.filter(m => m.is_lead).length}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-filters">
|
||||||
|
<select value={markFilter} onChange={e => setMarkFilter(e.target.value)}>
|
||||||
|
<option value="">全部标记</option>
|
||||||
|
{MARKS.map(m => <option key={m.key} value={m.key}>{m.label}标记</option>)}
|
||||||
|
</select>
|
||||||
|
<label className="lead-filter">
|
||||||
|
<input type="checkbox" checked={showLeadOnly} onChange={e => setShowLeadOnly(e.target.checked)} />
|
||||||
|
仅看精准客户
|
||||||
|
</label>
|
||||||
|
{focusUser && (
|
||||||
|
<button className="clear-focus" onClick={() => setFocusUser(null)}>清除用户筛选 ({focusUser})</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="msg-container" ref={scrollRef}>
|
||||||
|
{filteredMsgs.length === 0 ? (
|
||||||
|
<div className="no-msgs">暂无消息</div>
|
||||||
|
) : (
|
||||||
|
filteredMsgs.map(msg => (
|
||||||
|
<MsgCard
|
||||||
|
key={msg.id}
|
||||||
|
msg={msg}
|
||||||
|
isLead={msg.is_lead}
|
||||||
|
onFocus={() => setFocusUser(msg.user_id)}
|
||||||
|
onSeen={() => onSeen(room.id, msg.id)}
|
||||||
|
onLead={(v) => onLead(room.id, msg.id, v)}
|
||||||
|
onMark={(v) => onMark(room.id, msg.user_id, v)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-right">
|
||||||
|
<div className="user-panel">
|
||||||
|
<h3>👥 用户统计</h3>
|
||||||
|
<div className="user-list">
|
||||||
|
{userStats.slice(0, 50).map(u => (
|
||||||
|
<div key={u.name} className={`user-item ${focusUser === u.name ? 'focused' : ''} ${u.leads > 0 ? 'has-lead' : ''}`}>
|
||||||
|
<div className="user-info">
|
||||||
|
<span className="user-name" onClick={() => setFocusUser(focusUser === u.name ? null : u.name)}>{u.name}</span>
|
||||||
|
<span className="user-count">{u.count}次</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-actions">
|
||||||
|
{u.leads > 0 && <span className="user-lead-count">🎯{u.leads}</span>}
|
||||||
|
<select className="user-mark-select" value={MARKS.find(m => room.messages.some(mm => mm.user_id === (u.name)) && mm.marked === m.key)?.key || ''} onChange={e => {
|
||||||
|
const userId = u.name
|
||||||
|
onMark(room.id, userId, e.target.value || undefined)
|
||||||
|
}}>
|
||||||
|
<option value="">标记</option>
|
||||||
|
{MARKS.map(m => <option key={m.key} value={m.key}>{m.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MsgCardProps {
|
||||||
|
msg: RoomMessage
|
||||||
|
isLead: boolean
|
||||||
|
onFocus: () => void
|
||||||
|
onSeen: () => void
|
||||||
|
onLead: (v: boolean) => void
|
||||||
|
onMark: (v: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MsgCard({ msg, isLead, onFocus, onSeen, onLead, onMark }: MsgCardProps) {
|
||||||
|
const typeIcon = ICONS[msg.type as keyof typeof ICONS] || ICONS.chat
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`msg-card ${msg.type} ${isLead ? 'lead' : ''} ${msg.marked ? `marked-${msg.marked}` : ''} ${msg.is_seen ? 'seen' : ''}`}>
|
||||||
|
<div className="msg-header">
|
||||||
|
<span className="msg-type">{typeIcon} {TYPE_LABELS[msg.type] || msg.type}</span>
|
||||||
|
<span className="msg-user" onClick={onFocus} title="点击筛选该用户">{msg.username}</span>
|
||||||
|
{msg.fans_level > 0 && <span className="msg-fans">⭐{msg.fans_level}</span>}
|
||||||
|
<span className="msg-time">{formatTime(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="msg-body">
|
||||||
|
{msg.type === 'gift' && msg.gift_name && (
|
||||||
|
<span className="gift-info">{msg.gift_name} ×{msg.gift_num} ({msg.gift_price}抖币)</span>
|
||||||
|
)}
|
||||||
|
{msg.content && <span className="msg-text">{msg.content}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="msg-actions">
|
||||||
|
<button className={`mark-btn ${msg.marked || ''}`} onClick={() => onMark(msg.marked === 'blue' ? '' : 'blue')}>蓝</button>
|
||||||
|
<button className={`mark-btn ${msg.marked || ''}`} onClick={() => onMark(msg.marked === 'red' ? '' : 'red')}>红</button>
|
||||||
|
<button className={`mark-btn ${msg.marked || ''}`} onClick={() => onMark(msg.marked === 'yellow' ? '' : 'yellow')}>黄</button>
|
||||||
|
<button className={`lead-btn ${isLead ? 'active' : ''}`} onClick={() => onLead(!isLead)}>{isLead ? '🎯已精准' : '🎯精准'}</button>
|
||||||
|
{!msg.is_seen && <button className="seen-btn" onClick={onSeen}>✓已看</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
frontend/src/components/DocView.tsx
Normal file
75
frontend/src/components/DocView.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function DocView() {
|
||||||
|
return (
|
||||||
|
<div className="doc-view">
|
||||||
|
<button className="back-btn" onClick={() => window.history.back()}>← 返回</button>
|
||||||
|
<h2>📖 使用帮助</h2>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>一、快速开始</h3>
|
||||||
|
<ol>
|
||||||
|
<li>在首页输入框粘贴直播间URL(支持抖音直播间分享链接或主播主页链接)</li>
|
||||||
|
<li>点击「添加」按钮,系统会自动解析并开始监控</li>
|
||||||
|
<li>在房间卡片上点击「▶」启动监控,或点击「全部开始」一键启动所有房间</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>二、房间操作</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>启动/停止</strong>:点击卡片上的 ▶ / ⏹ 按钮,或鼠标悬停卡片显示控制按钮</li>
|
||||||
|
<li><strong>查看详情</strong>:点击卡片进入详情页,查看所有消息流</li>
|
||||||
|
<li><strong>删除房间</strong>:点击卡片右上角 ✕ 按钮</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>三、消息标记与精准客户</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>颜色标记</strong>:在消息操作栏点击「蓝」「红」「黄」,可对用户进行颜色标记,便于识别不同需求的用户</li>
|
||||||
|
<li><strong>精准客户</strong>:点击消息的「🎯精准」按钮,将该用户标记为精准客户</li>
|
||||||
|
<li><strong>已看标记</strong>:点击「✓已看」标记消息已浏览</li>
|
||||||
|
<li><strong>用户筛选</strong>:点击详情页右侧用户列表中的用户名,可筛选仅显示该用户的消息</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>四、视图切换</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>卡片视图</strong>:网格展示房间卡片,可通过列数选择器调整列数(2-5列)</li>
|
||||||
|
<li><strong>列表视图</strong>:表格展示房间列表,可通过每页条数选择器调整分页</li>
|
||||||
|
<li><strong>搜索过滤</strong>:在搜索框输入关键词,可按房间名或主播ID过滤</li>
|
||||||
|
<li><strong>类型过滤</strong>:点击「公开房」/「匿名房」按钮筛选房间类型</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>五、精准客户汇总</h3>
|
||||||
|
<p>点击顶部「🎯 客户」按钮进入客户名单页,查看所有房间的精准客户汇总,支持按房间筛选。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>六、主题切换</h3>
|
||||||
|
<p>点击顶栏 🌙/☀️ 按钮切换深色/浅色主题,主题偏好会自动保存。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>七、常见问题</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Q: 添加房间后显示「授权失效」?</dt>
|
||||||
|
<dd>A: 请点击顶栏「🔑 授权」按钮,输入授权码完成激活。</dd>
|
||||||
|
<dt>Q: 消息不实时更新?</dt>
|
||||||
|
<dd>A: 检查网络连接,如使用SSE连接失败会自动降级为轮询模式。</dd>
|
||||||
|
<dt>Q: 如何导出精准客户数据?</dt>
|
||||||
|
<dd>A: 在详情页点击精准客户筛选,右键复制消息内容,或联系开发者获取更多导出功能。</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="doc-section">
|
||||||
|
<h3>八、技术支持</h3>
|
||||||
|
<p>如有问题请联系客户服务中心:https://yser.ging.plus/</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
frontend/src/components/Header.tsx
Normal file
79
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ICONS } from '../constants'
|
||||||
|
import type { AppState } from '../types'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
roomCount: number
|
||||||
|
viewMode: 'card' | 'list'
|
||||||
|
setViewMode: (v: 'card' | 'list') => void
|
||||||
|
cardCols: number
|
||||||
|
setCardCols: (v: number) => void
|
||||||
|
listPerPage: number
|
||||||
|
setListPerPage: (v: number) => void
|
||||||
|
filterKind: 'all' | 'public' | 'anon'
|
||||||
|
setFilterKind: (v: 'all' | 'public' | 'anon') => void
|
||||||
|
searchQuery: string
|
||||||
|
setSearchQuery: (v: string) => void
|
||||||
|
needAuth: boolean
|
||||||
|
onLeads: () => void
|
||||||
|
onLicense: () => void
|
||||||
|
onDoc: () => void
|
||||||
|
onDebug: () => void
|
||||||
|
onLogToggle: () => void
|
||||||
|
onStartAll: () => void
|
||||||
|
onStopAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({
|
||||||
|
roomCount, viewMode, setViewMode, cardCols, setCardCols, listPerPage, setListPerPage,
|
||||||
|
filterKind, setFilterKind, searchQuery, setSearchQuery, needAuth,
|
||||||
|
onLeads, onLicense, onDoc, onDebug, onLogToggle, onStartAll, onStopAll
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="header">
|
||||||
|
<div className="header-left">
|
||||||
|
<span className="room-count">{ICONS.room} {roomCount} 个房间</span>
|
||||||
|
<div className="filter-group">
|
||||||
|
<button className={`filter-btn ${filterKind === 'all' ? 'active' : ''}`} onClick={() => setFilterKind('all')}>全部</button>
|
||||||
|
<button className={`filter-btn ${filterKind === 'public' ? 'active' : ''}`} onClick={() => setFilterKind('public')}>公开房</button>
|
||||||
|
<button className={`filter-btn ${filterKind === 'anon' ? 'active' : ''}`} onClick={() => setFilterKind('anon')}>匿名房</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索房间/主播..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<button className="view-toggle" onClick={onLeads} title="客户名单">🎯</button>
|
||||||
|
<button className={`view-toggle ${needAuth ? 'auth-warn' : ''}`} onClick={onLicense} title="授权">
|
||||||
|
{needAuth ? '⚠️' : '🔑'}
|
||||||
|
</button>
|
||||||
|
<button className="view-toggle" onClick={onDoc} title="文档">📖</button>
|
||||||
|
<button className="view-toggle" onClick={onDebug} title="调试">🔧</button>
|
||||||
|
<button className={`view-toggle ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} title="卡片视图">🗂</button>
|
||||||
|
<button className={`view-toggle ${viewMode === 'list' ? 'active' : ''}`} onClick={() => setViewMode('list')} title="列表视图">☰</button>
|
||||||
|
{viewMode === 'card' ? (
|
||||||
|
<select className="col-select" value={cardCols} onChange={e => setCardCols(Number(e.target.value))}>
|
||||||
|
<option value={2}>2列</option>
|
||||||
|
<option value={3}>3列</option>
|
||||||
|
<option value={4}>4列</option>
|
||||||
|
<option value={5}>5列</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<select className="col-select" value={listPerPage} onChange={e => setListPerPage(Number(e.target.value))}>
|
||||||
|
<option value={10}>10条/页</option>
|
||||||
|
<option value={20}>20条/页</option>
|
||||||
|
<option value={50}>50条/页</option>
|
||||||
|
<option value={100}>100条/页</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button className="action-btn start-btn" onClick={onStartAll}>▶ 全部开始</button>
|
||||||
|
<button className="action-btn stop-btn" onClick={onStopAll}>⏹ 全部停止</button>
|
||||||
|
<button className="view-toggle" onClick={onLogToggle} title="日志开关">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/components/KindBadge.tsx
Normal file
13
frontend/src/components/KindBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface KindBadgeProps {
|
||||||
|
kind: 'public' | 'anon'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KindBadge({ kind }: KindBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className={`kind-badge ${kind}`}>
|
||||||
|
{kind === 'public' ? '📢 公开' : '🎭 匿名'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
frontend/src/components/LeadView.tsx
Normal file
53
frontend/src/components/LeadView.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import type { RoomData } from '../types'
|
||||||
|
|
||||||
|
interface LeadViewProps {
|
||||||
|
rooms: RoomData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeadView({ rooms }: LeadViewProps) {
|
||||||
|
const leads = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; room: string; msgs: { content: string; time: number; type: string }[] }>()
|
||||||
|
rooms.forEach(room => {
|
||||||
|
room.messages.filter(m => m.is_lead).forEach(msg => {
|
||||||
|
const existing = map.get(msg.user_id)
|
||||||
|
if (existing) {
|
||||||
|
existing.msgs.push({ content: msg.content, time: msg.timestamp, type: msg.type })
|
||||||
|
} else {
|
||||||
|
map.set(msg.user_id, { name: msg.username, room: room.title || room.anchor_id, msgs: [{ content: msg.content, time: msg.timestamp, type: msg.type }] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.msgs.length - a.msgs.length)
|
||||||
|
}, [rooms])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lead-view">
|
||||||
|
<h2>🎯 跨房间精准客户汇总</h2>
|
||||||
|
{leads.length === 0 ? (
|
||||||
|
<div className="empty-state"><p>暂无精准客户</p></div>
|
||||||
|
) : (
|
||||||
|
<div className="lead-list">
|
||||||
|
{leads.map(lead => (
|
||||||
|
<div key={lead.name} className="lead-item">
|
||||||
|
<div className="lead-header">
|
||||||
|
<span className="lead-name">{lead.name}</span>
|
||||||
|
<span className="lead-room">{lead.room}</span>
|
||||||
|
<span className="lead-count">{lead.msgs.length}条精准消息</span>
|
||||||
|
</div>
|
||||||
|
<div className="lead-msgs">
|
||||||
|
{lead.msgs.slice(-10).reverse().map((m, i) => (
|
||||||
|
<div key={i} className="lead-msg">
|
||||||
|
<span className="lead-msg-type">{m.type}</span>
|
||||||
|
<span className="lead-msg-content">{m.content}</span>
|
||||||
|
<span className="lead-msg-time">{new Date(m.time).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
frontend/src/components/LicenseView.tsx
Normal file
108
frontend/src/components/LicenseView.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import type { LicenseState } from '../types'
|
||||||
|
|
||||||
|
interface LicenseViewProps {
|
||||||
|
license: LicenseState | null
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LicenseView({ license, onBack }: LicenseViewProps) {
|
||||||
|
const [step, setStep] = useState<'login' | 'scan' | 'activate'>('login')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (license?.state === 'active') setStep('activate')
|
||||||
|
else if (license?.qrcode) setStep('scan')
|
||||||
|
}, [license])
|
||||||
|
|
||||||
|
const handleActivate = async () => {
|
||||||
|
if (!token.trim()) return
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/license/activate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: token.trim() })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
setStatus('授权成功!')
|
||||||
|
setTimeout(onBack, 1500)
|
||||||
|
} else {
|
||||||
|
setStatus(data.error || '授权失败,请检查token')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('网络错误,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="license-view">
|
||||||
|
<button className="back-btn" onClick={onBack}>← 返回</button>
|
||||||
|
<h2>🔑 订阅授权</h2>
|
||||||
|
|
||||||
|
{license?.state === 'active' ? (
|
||||||
|
<div className="license-active">
|
||||||
|
<div className="license-card">
|
||||||
|
<h3>✅ 授权有效</h3>
|
||||||
|
<p>计划: {license.plan}</p>
|
||||||
|
<p>有效期至: {license.expires_at ? new Date(license.expires_at).toLocaleDateString() : '永久'}</p>
|
||||||
|
<p>最大房间数: {license.max_rooms}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : license?.state === 'expired' ? (
|
||||||
|
<div className="license-expired">
|
||||||
|
<p>⚠️ 授权已过期,请重新激活</p>
|
||||||
|
<ActivateForm token={token} setToken={setToken} status={status} onActivate={handleActivate} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="license-unconfigured">
|
||||||
|
<p>🔒 尚未配置授权,请选择以下方式激活:</p>
|
||||||
|
<div className="license-steps">
|
||||||
|
<div className="step">
|
||||||
|
<h4>步骤 1: 登录/注册</h4>
|
||||||
|
<p>访问客户服务中心获取授权码</p>
|
||||||
|
<button className="open-url-btn" onClick={() => (window as any).BrowserOpenURL?.('https://yser.ging.plus/')}>
|
||||||
|
打开客户服务中心
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="step">
|
||||||
|
<h4>步骤 2: 输入授权码</h4>
|
||||||
|
<ActivateForm token={token} setToken={setToken} status={status} onActivate={handleActivate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{license?.qrcode && (
|
||||||
|
<div className="qrcode-wrap">
|
||||||
|
<h4>扫码激活</h4>
|
||||||
|
<QRCodeSVG value={license.qrcode} size={200} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivateForm({ token, setToken, status, onActivate }: {
|
||||||
|
token: string
|
||||||
|
setToken: (v: string) => void
|
||||||
|
status: string
|
||||||
|
onActivate: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="activate-form">
|
||||||
|
<input
|
||||||
|
className="token-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入授权码..."
|
||||||
|
value={token}
|
||||||
|
onChange={e => setToken(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && onActivate()}
|
||||||
|
/>
|
||||||
|
<button className="activate-btn" onClick={onActivate}>激活授权</button>
|
||||||
|
{status && <span className="activate-status">{status}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
frontend/src/components/RoomCard.tsx
Normal file
61
frontend/src/components/RoomCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import KindBadge from './KindBadge'
|
||||||
|
import { ICONS, TYPE_LABELS } from '../constants'
|
||||||
|
import type { RoomData } from '../types'
|
||||||
|
|
||||||
|
interface RoomCardProps {
|
||||||
|
room: RoomData
|
||||||
|
onToggle: (action: 'start' | 'stop' | 'toggle') => void
|
||||||
|
onRemove: () => void
|
||||||
|
onDetail: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomCard({ room, onToggle, onRemove, onDetail }: RoomCardProps) {
|
||||||
|
const lastMsgs = room.messages.slice(-5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`room-card ${room.status}`}>
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="card-title-wrap">
|
||||||
|
<KindBadge kind={room.kind} />
|
||||||
|
<span className="card-title" title={room.title}>{room.title || room.anchor_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-controls">
|
||||||
|
<button className="card-btn detail" onClick={onDetail} title="详情">🔍</button>
|
||||||
|
<button className={`card-btn ${room.status === 'running' ? 'stop' : 'start'}`} onClick={() => onToggle(room.status === 'running' ? 'stop' : 'start')} title={room.status === 'running' ? '停止' : '启动'}>
|
||||||
|
{room.status === 'running' ? '⏹' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button className="card-btn del" onClick={onRemove} title="删除">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-stats">
|
||||||
|
<span className="stat">👁 {room.viewers}</span>
|
||||||
|
<span className="stat">💬 {room.total_msg}</span>
|
||||||
|
<span className="stat lead-stat">🎯 {room.lead_count}</span>
|
||||||
|
<span className={`stat status-stat ${room.status}`}>{room.status === 'running' ? '● 直播中' : room.status === 'starting' ? '◌ 启动中' : room.status === 'error' ? '✕ 错误' : '○ 已停止'}</span>
|
||||||
|
</div>
|
||||||
|
{lastMsgs.length > 0 && (
|
||||||
|
<div className="card-preview">
|
||||||
|
{lastMsgs.map(msg => (
|
||||||
|
<div key={msg.id} className={`preview-msg ${msg.is_lead ? 'lead' : ''} ${msg.marked ? `marked-${msg.marked}` : ''}`}>
|
||||||
|
<span className="msg-type-icon">{ICONS[msg.type as keyof typeof ICONS] || ICONS.chat}</span>
|
||||||
|
<span className="msg-content">{truncate(msg.content, 40)}</span>
|
||||||
|
<span className="msg-time">{formatTime(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n: number) {
|
||||||
|
return s.length > n ? s.slice(0, n) + '...' : s
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number) {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
35
frontend/src/components/RoomTools.tsx
Normal file
35
frontend/src/components/RoomTools.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface RoomToolsProps {
|
||||||
|
viewMode?: 'card' | 'list'
|
||||||
|
cardCols?: number
|
||||||
|
listPerPage?: number
|
||||||
|
onToggleView?: (v: 'card' | 'list') => void
|
||||||
|
onSetCols?: (n: number) => void
|
||||||
|
onSetPerPage?: (n: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomTools({ viewMode, cardCols, listPerPage, onToggleView, onSetCols, onSetPerPage }: RoomToolsProps) {
|
||||||
|
return (
|
||||||
|
<div className="room-tools">
|
||||||
|
<button className={`tool-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => onToggleView?.('card')} title="卡片视图">🗂</button>
|
||||||
|
<button className={`tool-btn ${viewMode === 'list' ? 'active' : ''}`} onClick={() => onToggleView?.('list')} title="列表视图">☰</button>
|
||||||
|
{viewMode === 'card' && (
|
||||||
|
<div className="col-sizer">
|
||||||
|
<span className="col-label">列:</span>
|
||||||
|
{[2, 3, 4, 5].map(n => (
|
||||||
|
<button key={n} className={`col-btn ${cardCols === n ? 'active' : ''}`} onClick={() => onSetCols?.(n)}>{n}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className="col-sizer">
|
||||||
|
<span className="col-label">页:</span>
|
||||||
|
{[10, 20, 50, 100].map(n => (
|
||||||
|
<button key={n} className={`col-btn ${listPerPage === n ? 'active' : ''}`} onClick={() => onSetPerPage?.(n)}>{n}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/constants.ts
Normal file
39
frontend/src/constants.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const ICONS = {
|
||||||
|
chat: '💬',
|
||||||
|
gift: '🎁',
|
||||||
|
enter: '👋',
|
||||||
|
like: '❤️',
|
||||||
|
follow: '➕',
|
||||||
|
stats: '📊',
|
||||||
|
fansclub: '⭐',
|
||||||
|
status: 'ℹ️',
|
||||||
|
rank: '🏆',
|
||||||
|
room: '🏠',
|
||||||
|
room_stats: '📊',
|
||||||
|
emoji_chat: '😀',
|
||||||
|
stream_adaptation: '⚙️',
|
||||||
|
lead: '🎯'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const MARKS = [
|
||||||
|
{ key: 'blue', label: '蓝', color: '#4a9eff', tint: 'rgba(74,158,255,0.10)' },
|
||||||
|
{ key: 'red', label: '红', color: '#ff4d4f', tint: 'rgba(255,77,79,0.10)' },
|
||||||
|
{ key: 'yellow', label: '黄', color: '#ffc53d', tint: 'rgba(255,197,61,0.10)' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const MARK_MAP = Object.fromEntries(MARKS.map(m => [m.key, m])) as Record<string, typeof MARKS[0]>
|
||||||
|
|
||||||
|
export const TYPE_LABELS: Record<string, string> = {
|
||||||
|
all: '全部',
|
||||||
|
chat: '聊天',
|
||||||
|
gift: '礼物',
|
||||||
|
enter: '进场',
|
||||||
|
like: '点赞',
|
||||||
|
follow: '关注',
|
||||||
|
stats: '统计',
|
||||||
|
fansclub: '粉丝团',
|
||||||
|
rank: '排行榜',
|
||||||
|
status: '状态',
|
||||||
|
room_stats: '统计',
|
||||||
|
lead: '精准客户'
|
||||||
|
}
|
||||||
24
frontend/src/errorReporter.ts
Normal file
24
frontend/src/errorReporter.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function errorReporter() {
|
||||||
|
window.onerror = (msg, src, line, col, err) => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'js_error',
|
||||||
|
message: String(msg),
|
||||||
|
source: src,
|
||||||
|
line,
|
||||||
|
column: col,
|
||||||
|
stack: err ? (err.stack || '') : '',
|
||||||
|
ts: Date.now()
|
||||||
|
})
|
||||||
|
fetch('/api/error', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }).catch(() => {})
|
||||||
|
}
|
||||||
|
window.addEventListener('unhandledrejection', e => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'unhandled_rejection',
|
||||||
|
message: (e.reason && (e.reason as any).message) || String(e.reason),
|
||||||
|
ts: Date.now()
|
||||||
|
})
|
||||||
|
fetch('/api/error', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }).catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
8
frontend/src/main.tsx
Normal file
8
frontend/src/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './style.css'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<App />
|
||||||
|
)
|
||||||
28
frontend/src/style.css
Normal file
28
frontend/src/style.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
51
frontend/src/types.ts
Normal file
51
frontend/src/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ICONS, TYPE_LABELS } from './constants'
|
||||||
|
|
||||||
|
export interface RoomMessage {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
user_id: string
|
||||||
|
username: string
|
||||||
|
fans_level: number
|
||||||
|
content: string
|
||||||
|
gift_name?: string
|
||||||
|
gift_num?: number
|
||||||
|
gift_price?: number
|
||||||
|
timestamp: number
|
||||||
|
marked?: string
|
||||||
|
is_lead?: boolean
|
||||||
|
is_seen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomData {
|
||||||
|
id: string
|
||||||
|
anchor_id: string
|
||||||
|
room_id: string
|
||||||
|
title: string
|
||||||
|
kind: 'public' | 'anon'
|
||||||
|
status: 'stopped' | 'starting' | 'running' | 'error'
|
||||||
|
viewers: number
|
||||||
|
total_msg: number
|
||||||
|
lead_count: number
|
||||||
|
messages: RoomMessage[]
|
||||||
|
added_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SysState {
|
||||||
|
version: string
|
||||||
|
rooms_total: number
|
||||||
|
running: number
|
||||||
|
error_count: number
|
||||||
|
uptime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenseState {
|
||||||
|
state: 'unconfigured' | 'active' | 'expired' | 'trial'
|
||||||
|
plan: string
|
||||||
|
expires_at: number
|
||||||
|
max_rooms: number
|
||||||
|
qrcode?: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Page = 'home' | 'leads' | 'doc' | 'debug' | 'license' | 'detail'
|
||||||
23
frontend/src/utils.ts
Normal file
23
frontend/src/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function formatTime(ts: number): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLs<T>(key: string, init: T): [T, (v: T) => void] {
|
||||||
|
const [val, setVal] = React.useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const s = localStorage.getItem(key)
|
||||||
|
return s ? JSON.parse(s) : init
|
||||||
|
} catch {
|
||||||
|
return init
|
||||||
|
}
|
||||||
|
})
|
||||||
|
React.useEffect(() => {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(val)) } catch {}
|
||||||
|
}, [key, val])
|
||||||
|
return [val, setVal]
|
||||||
|
}
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/',
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/stream': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
})
|
||||||
5
frontend/wailsjs/go/main/App.d.ts
vendored
Normal file
5
frontend/wailsjs/go/main/App.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {context} from '../models';
|
||||||
|
|
||||||
|
export function Startup(arg1:context.Context):Promise<void>;
|
||||||
7
frontend/wailsjs/go/main/App.js
Normal file
7
frontend/wailsjs/go/main/App.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function Startup(arg1) {
|
||||||
|
return window['go']['main']['App']['Startup'](arg1);
|
||||||
|
}
|
||||||
24
frontend/wailsjs/runtime/package.json
Normal file
24
frontend/wailsjs/runtime/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@wailsapp/runtime",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Wails Javascript runtime library",
|
||||||
|
"main": "runtime.js",
|
||||||
|
"types": "runtime.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/wailsapp/wails.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"Wails",
|
||||||
|
"Javascript",
|
||||||
|
"Go"
|
||||||
|
],
|
||||||
|
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/wailsapp/wails/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||||
|
}
|
||||||
330
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
330
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Screen {
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPrimary: boolean;
|
||||||
|
width : number
|
||||||
|
height : number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment information such as platform, buildtype, ...
|
||||||
|
export interface EnvironmentInfo {
|
||||||
|
buildType: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||||
|
// emits the given event. Optional data may be passed with the event.
|
||||||
|
// This will trigger any event listeners.
|
||||||
|
export function EventsEmit(eventName: string, ...data: any): void;
|
||||||
|
|
||||||
|
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||||
|
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||||
|
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||||
|
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||||
|
|
||||||
|
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||||
|
// sets up a listener for the given event name, but will only trigger once.
|
||||||
|
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||||
|
// unregisters the listener for the given event name.
|
||||||
|
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||||
|
|
||||||
|
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||||
|
// unregisters all listeners.
|
||||||
|
export function EventsOffAll(): void;
|
||||||
|
|
||||||
|
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||||
|
// logs the given message as a raw message
|
||||||
|
export function LogPrint(message: string): void;
|
||||||
|
|
||||||
|
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||||
|
// logs the given message at the `trace` log level.
|
||||||
|
export function LogTrace(message: string): void;
|
||||||
|
|
||||||
|
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||||
|
// logs the given message at the `debug` log level.
|
||||||
|
export function LogDebug(message: string): void;
|
||||||
|
|
||||||
|
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||||
|
// logs the given message at the `error` log level.
|
||||||
|
export function LogError(message: string): void;
|
||||||
|
|
||||||
|
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||||
|
// logs the given message at the `fatal` log level.
|
||||||
|
// The application will quit after calling this method.
|
||||||
|
export function LogFatal(message: string): void;
|
||||||
|
|
||||||
|
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||||
|
// logs the given message at the `info` log level.
|
||||||
|
export function LogInfo(message: string): void;
|
||||||
|
|
||||||
|
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||||
|
// logs the given message at the `warning` log level.
|
||||||
|
export function LogWarning(message: string): void;
|
||||||
|
|
||||||
|
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||||
|
// Forces a reload by the main application as well as connected browsers.
|
||||||
|
export function WindowReload(): void;
|
||||||
|
|
||||||
|
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||||
|
// Reloads the application frontend.
|
||||||
|
export function WindowReloadApp(): void;
|
||||||
|
|
||||||
|
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||||
|
// Sets the window AlwaysOnTop or not on top.
|
||||||
|
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||||
|
|
||||||
|
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window theme to system default (dark/light).
|
||||||
|
export function WindowSetSystemDefaultTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to light theme.
|
||||||
|
export function WindowSetLightTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to dark theme.
|
||||||
|
export function WindowSetDarkTheme(): void;
|
||||||
|
|
||||||
|
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||||
|
// Centers the window on the monitor the window is currently on.
|
||||||
|
export function WindowCenter(): void;
|
||||||
|
|
||||||
|
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||||
|
// Sets the text in the window title bar.
|
||||||
|
export function WindowSetTitle(title: string): void;
|
||||||
|
|
||||||
|
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||||
|
// Makes the window full screen.
|
||||||
|
export function WindowFullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||||
|
// Restores the previous window dimensions and position prior to full screen.
|
||||||
|
export function WindowUnfullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||||
|
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||||
|
export function WindowIsFullscreen(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||||
|
// Sets the width and height of the window.
|
||||||
|
export function WindowSetSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||||
|
// Gets the width and height of the window.
|
||||||
|
export function WindowGetSize(): Promise<Size>;
|
||||||
|
|
||||||
|
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||||
|
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMaxSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||||
|
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMinSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||||
|
// Sets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowSetPosition(x: number, y: number): void;
|
||||||
|
|
||||||
|
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||||
|
// Gets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowGetPosition(): Promise<Position>;
|
||||||
|
|
||||||
|
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||||
|
// Hides the window.
|
||||||
|
export function WindowHide(): void;
|
||||||
|
|
||||||
|
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||||
|
// Shows the window, if it is currently hidden.
|
||||||
|
export function WindowShow(): void;
|
||||||
|
|
||||||
|
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||||
|
// Maximises the window to fill the screen.
|
||||||
|
export function WindowMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||||
|
// Toggles between Maximised and UnMaximised.
|
||||||
|
export function WindowToggleMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||||
|
// Restores the window to the dimensions and position prior to maximising.
|
||||||
|
export function WindowUnmaximise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||||
|
export function WindowIsMaximised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||||
|
// Minimises the window.
|
||||||
|
export function WindowMinimise(): void;
|
||||||
|
|
||||||
|
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||||
|
// Restores the window to the dimensions and position prior to minimising.
|
||||||
|
export function WindowUnminimise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||||
|
export function WindowIsMinimised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||||
|
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||||
|
export function WindowIsNormal(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||||
|
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||||
|
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||||
|
|
||||||
|
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||||
|
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||||
|
export function ScreenGetAll(): Promise<Screen[]>;
|
||||||
|
|
||||||
|
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||||
|
// Opens the given URL in the system browser.
|
||||||
|
export function BrowserOpenURL(url: string): void;
|
||||||
|
|
||||||
|
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||||
|
// Returns information about the environment
|
||||||
|
export function Environment(): Promise<EnvironmentInfo>;
|
||||||
|
|
||||||
|
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||||
|
// Quits the application.
|
||||||
|
export function Quit(): void;
|
||||||
|
|
||||||
|
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||||
|
// Hides the application.
|
||||||
|
export function Hide(): void;
|
||||||
|
|
||||||
|
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||||
|
// Shows the application.
|
||||||
|
export function Show(): void;
|
||||||
|
|
||||||
|
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||||
|
// Returns the current text stored on clipboard
|
||||||
|
export function ClipboardGetText(): Promise<string>;
|
||||||
|
|
||||||
|
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||||
|
// Sets a text on the clipboard
|
||||||
|
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||||
|
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||||
|
|
||||||
|
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||||
|
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
export function OnFileDropOff() :void
|
||||||
|
|
||||||
|
// Check if the file path resolver is available
|
||||||
|
export function CanResolveFilePaths(): boolean;
|
||||||
|
|
||||||
|
// Resolves file paths for an array of files
|
||||||
|
export function ResolveFilePaths(files: File[]): void
|
||||||
|
|
||||||
|
// Notification types
|
||||||
|
export interface NotificationOptions {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string; // macOS and Linux only
|
||||||
|
body?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
data?: { [key: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationAction {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
destructive?: boolean; // macOS-specific
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationCategory {
|
||||||
|
id?: string;
|
||||||
|
actions?: NotificationAction[];
|
||||||
|
hasReplyField?: boolean;
|
||||||
|
replyPlaceholder?: string;
|
||||||
|
replyButtonTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
|
||||||
|
// Initializes the notification service for the application.
|
||||||
|
// This must be called before sending any notifications.
|
||||||
|
export function InitializeNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
|
||||||
|
// Cleans up notification resources and releases any held connections.
|
||||||
|
export function CleanupNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
|
||||||
|
// Checks if notifications are available on the current platform.
|
||||||
|
export function IsNotificationAvailable(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
|
||||||
|
// Requests notification authorization from the user (macOS only).
|
||||||
|
export function RequestNotificationAuthorization(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
|
||||||
|
// Checks the current notification authorization status (macOS only).
|
||||||
|
export function CheckNotificationAuthorization(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
|
||||||
|
// Sends a basic notification with the given options.
|
||||||
|
export function SendNotification(options: NotificationOptions): Promise<void>;
|
||||||
|
|
||||||
|
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
|
||||||
|
// Sends a notification with action buttons. Requires a registered category.
|
||||||
|
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
|
||||||
|
|
||||||
|
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
|
||||||
|
// Registers a notification category that can be used with SendNotificationWithActions.
|
||||||
|
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
|
||||||
|
// Removes a previously registered notification category.
|
||||||
|
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
|
||||||
|
// Removes all pending notifications from the notification center.
|
||||||
|
export function RemoveAllPendingNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
|
||||||
|
// Removes a specific pending notification by its identifier.
|
||||||
|
export function RemovePendingNotification(identifier: string): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
|
||||||
|
// Removes all delivered notifications from the notification center.
|
||||||
|
export function RemoveAllDeliveredNotifications(): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
|
||||||
|
// Removes a specific delivered notification by its identifier.
|
||||||
|
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
|
||||||
|
|
||||||
|
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
|
||||||
|
// Removes a notification by its identifier (cross-platform convenience function).
|
||||||
|
export function RemoveNotification(identifier: string): Promise<void>;
|
||||||
298
frontend/wailsjs/runtime/runtime.js
Normal file
298
frontend/wailsjs/runtime/runtime.js
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function LogPrint(message) {
|
||||||
|
window.runtime.LogPrint(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogTrace(message) {
|
||||||
|
window.runtime.LogTrace(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogDebug(message) {
|
||||||
|
window.runtime.LogDebug(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogInfo(message) {
|
||||||
|
window.runtime.LogInfo(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogWarning(message) {
|
||||||
|
window.runtime.LogWarning(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogError(message) {
|
||||||
|
window.runtime.LogError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogFatal(message) {
|
||||||
|
window.runtime.LogFatal(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||||
|
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOn(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOff(eventName, ...additionalEventNames) {
|
||||||
|
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOffAll() {
|
||||||
|
return window.runtime.EventsOffAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnce(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsEmit(eventName) {
|
||||||
|
let args = [eventName].slice.call(arguments);
|
||||||
|
return window.runtime.EventsEmit.apply(null, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReload() {
|
||||||
|
window.runtime.WindowReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReloadApp() {
|
||||||
|
window.runtime.WindowReloadApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetAlwaysOnTop(b) {
|
||||||
|
window.runtime.WindowSetAlwaysOnTop(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSystemDefaultTheme() {
|
||||||
|
window.runtime.WindowSetSystemDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetLightTheme() {
|
||||||
|
window.runtime.WindowSetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetDarkTheme() {
|
||||||
|
window.runtime.WindowSetDarkTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowCenter() {
|
||||||
|
window.runtime.WindowCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetTitle(title) {
|
||||||
|
window.runtime.WindowSetTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowFullscreen() {
|
||||||
|
window.runtime.WindowFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnfullscreen() {
|
||||||
|
window.runtime.WindowUnfullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsFullscreen() {
|
||||||
|
return window.runtime.WindowIsFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetSize() {
|
||||||
|
return window.runtime.WindowGetSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSize(width, height) {
|
||||||
|
window.runtime.WindowSetSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMaxSize(width, height) {
|
||||||
|
window.runtime.WindowSetMaxSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMinSize(width, height) {
|
||||||
|
window.runtime.WindowSetMinSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetPosition(x, y) {
|
||||||
|
window.runtime.WindowSetPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetPosition() {
|
||||||
|
return window.runtime.WindowGetPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowHide() {
|
||||||
|
window.runtime.WindowHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowShow() {
|
||||||
|
window.runtime.WindowShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMaximise() {
|
||||||
|
window.runtime.WindowMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowToggleMaximise() {
|
||||||
|
window.runtime.WindowToggleMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnmaximise() {
|
||||||
|
window.runtime.WindowUnmaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMaximised() {
|
||||||
|
return window.runtime.WindowIsMaximised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMinimise() {
|
||||||
|
window.runtime.WindowMinimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnminimise() {
|
||||||
|
window.runtime.WindowUnminimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||||
|
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenGetAll() {
|
||||||
|
return window.runtime.ScreenGetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMinimised() {
|
||||||
|
return window.runtime.WindowIsMinimised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsNormal() {
|
||||||
|
return window.runtime.WindowIsNormal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserOpenURL(url) {
|
||||||
|
window.runtime.BrowserOpenURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Environment() {
|
||||||
|
return window.runtime.Environment();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Quit() {
|
||||||
|
window.runtime.Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hide() {
|
||||||
|
window.runtime.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Show() {
|
||||||
|
window.runtime.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardGetText() {
|
||||||
|
return window.runtime.ClipboardGetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardSetText(text) {
|
||||||
|
return window.runtime.ClipboardSetText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @callback OnFileDropCallback
|
||||||
|
* @param {number} x - x coordinate of the drop
|
||||||
|
* @param {number} y - y coordinate of the drop
|
||||||
|
* @param {string[]} paths - A list of file paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||||
|
*/
|
||||||
|
export function OnFileDrop(callback, useDropTarget) {
|
||||||
|
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
*/
|
||||||
|
export function OnFileDropOff() {
|
||||||
|
return window.runtime.OnFileDropOff();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanResolveFilePaths() {
|
||||||
|
return window.runtime.CanResolveFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResolveFilePaths(files) {
|
||||||
|
return window.runtime.ResolveFilePaths(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitializeNotifications() {
|
||||||
|
return window.runtime.InitializeNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CleanupNotifications() {
|
||||||
|
return window.runtime.CleanupNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsNotificationAvailable() {
|
||||||
|
return window.runtime.IsNotificationAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestNotificationAuthorization() {
|
||||||
|
return window.runtime.RequestNotificationAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckNotificationAuthorization() {
|
||||||
|
return window.runtime.CheckNotificationAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendNotification(options) {
|
||||||
|
return window.runtime.SendNotification(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendNotificationWithActions(options) {
|
||||||
|
return window.runtime.SendNotificationWithActions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterNotificationCategory(category) {
|
||||||
|
return window.runtime.RegisterNotificationCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveNotificationCategory(categoryId) {
|
||||||
|
return window.runtime.RemoveNotificationCategory(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveAllPendingNotifications() {
|
||||||
|
return window.runtime.RemoveAllPendingNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemovePendingNotification(identifier) {
|
||||||
|
return window.runtime.RemovePendingNotification(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveAllDeliveredNotifications() {
|
||||||
|
return window.runtime.RemoveAllDeliveredNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveDeliveredNotification(identifier) {
|
||||||
|
return window.runtime.RemoveDeliveredNotification(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveNotification(identifier) {
|
||||||
|
return window.runtime.RemoveNotification(identifier);
|
||||||
|
}
|
||||||
51
go.mod
Normal file
51
go.mod
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
module github.com/qingwuyingxing/douyin-live-fetcher
|
||||||
|
|
||||||
|
go 1.25.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/labstack/echo/v4 v4.15.4
|
||||||
|
github.com/wailsapp/wails/v2 v2.16.0
|
||||||
|
modernc.org/sqlite v1.59.0
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/gommon v0.5.0 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.39.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
)
|
||||||
139
go.sum
Normal file
139
go.sum
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||||
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||||
|
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
|
github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b h1:UMDLDHFR1Chu3qnsPNCrVxq0lZgG6JqHpLL5+iqfSkw=
|
||||||
|
github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||||
|
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||||
|
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||||
|
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.16.0 h1:hLYrSHUUq6hRXl3rWT9ZXiyFIgM94nVN9Ns6iEJzxyw=
|
||||||
|
github.com/wailsapp/wails/v2 v2.16.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||||
|
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||||
|
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||||
|
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||||
|
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||||
|
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
BIN
go_win_template.exe~
Normal file
BIN
go_win_template.exe~
Normal file
Binary file not shown.
803
internal/api/server.go
Normal file
803
internal/api/server.go
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/broadcaster"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/db"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/fetcher"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/license"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
fetchers = make(map[string]*fetcher.Fetcher)
|
||||||
|
fetcherMu sync.RWMutex
|
||||||
|
bc *broadcaster.Broadcaster
|
||||||
|
startedAt = time.Now()
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartServer(b *broadcaster.Broadcaster, assets http.FileSystem, port int) error {
|
||||||
|
bc = b
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{"*"},
|
||||||
|
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Content-Type"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
g := e.Group("/api")
|
||||||
|
g.POST("/start", handleStart)
|
||||||
|
g.POST("/stop", handleStop)
|
||||||
|
g.POST("/del", handleDel)
|
||||||
|
g.GET("/rooms", handleRooms)
|
||||||
|
g.GET("/rooms_db", handleRoomsDB)
|
||||||
|
g.POST("/rooms", handleAddRoom)
|
||||||
|
g.GET("/messages", handleMessages)
|
||||||
|
g.GET("/users", handleUsers)
|
||||||
|
g.GET("/users_db", handleUsersDB)
|
||||||
|
g.GET("/marks", handleMarksGet)
|
||||||
|
g.POST("/mark", handleMarkSet)
|
||||||
|
g.POST("/marks_clear", handleMarksClear)
|
||||||
|
g.POST("/msg_seen", handleMsgSeen)
|
||||||
|
g.POST("/msg_seen_bulk", handleMsgSeenBulk)
|
||||||
|
g.POST("/msg_seen_clear", handleMsgSeenClear)
|
||||||
|
g.POST("/lead", handleLead)
|
||||||
|
g.GET("/leads", handleLeads)
|
||||||
|
g.GET("/leads_export", handleLeadsExport)
|
||||||
|
g.GET("/export", handleExport)
|
||||||
|
g.GET("/keywords", handleKeywordsGet)
|
||||||
|
g.POST("/keywords", handleKeywordsSet)
|
||||||
|
g.GET("/resolve", handleResolve)
|
||||||
|
g.GET("/status", handleStatus)
|
||||||
|
g.GET("/stream", handleStream)
|
||||||
|
g.GET("/errors", handleErrors)
|
||||||
|
g.POST("/error", handleError)
|
||||||
|
g.GET("/license", handleLicenseGet)
|
||||||
|
g.POST("/license", handleLicensePost)
|
||||||
|
g.POST("/license/logout", handleLicenseLogout)
|
||||||
|
g.GET("/settings", handleSettingsGet)
|
||||||
|
g.POST("/settings", handleSettingsPost)
|
||||||
|
|
||||||
|
// 静态文件服务(直接读文件系统)
|
||||||
|
e.GET("/", func(c echo.Context) error {
|
||||||
|
return c.File("frontend/dist/index.html")
|
||||||
|
})
|
||||||
|
e.Static("/assets", "frontend/dist/assets")
|
||||||
|
|
||||||
|
go _autoWatch()
|
||||||
|
go _licenseWatch()
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", port)
|
||||||
|
log.Printf("[api] starting server on %s", addr)
|
||||||
|
return e.Start(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveFile(c echo.Context, fs http.FileSystem, name string) error {
|
||||||
|
f, err := fs.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[serveFile] open %q failed: %v", name, err)
|
||||||
|
return c.String(404, "not found")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[serveFile] read %q failed: %v", name, err)
|
||||||
|
return c.String(500, "read failed")
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("Content-Type", http.DetectContentType(data))
|
||||||
|
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
c.Response().WriteHeader(200)
|
||||||
|
_, err = c.Response().Write(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[serveFile] write %q failed: %v", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 房间 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleStart(c echo.Context) error {
|
||||||
|
if !license.LicenseAllowed(false) {
|
||||||
|
return c.JSON(403, map[string]string{"error": "授权不可用"})
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
Keywords string `json:"keywords"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(400, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "无效直播间ID"})
|
||||||
|
}
|
||||||
|
kws := body.Keywords
|
||||||
|
if kws == "" {
|
||||||
|
kws = db.GetSetting("kw_" + lid)
|
||||||
|
}
|
||||||
|
db.SaveRoom(lid, kws)
|
||||||
|
startFetcher(lid, kws, "manual")
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok", "live_id": lid})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStop(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := strings.TrimSpace(body.LiveID)
|
||||||
|
fetcherMu.Lock()
|
||||||
|
if lid == "all" {
|
||||||
|
for id := range fetchers {
|
||||||
|
stopFetcher(id)
|
||||||
|
}
|
||||||
|
fetcherMu.Unlock()
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok", "message": "Stopped all"})
|
||||||
|
}
|
||||||
|
if lid != "" {
|
||||||
|
stopFetcher(lid)
|
||||||
|
}
|
||||||
|
fetcherMu.Unlock()
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDel(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
fetcherMu.Lock()
|
||||||
|
stopFetcher(lid)
|
||||||
|
fetcherMu.Unlock()
|
||||||
|
db.DelRoom(lid)
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRooms(c echo.Context) error {
|
||||||
|
fetcherMu.RLock()
|
||||||
|
rooms := make([]map[string]interface{}, 0)
|
||||||
|
for id, f := range fetchers {
|
||||||
|
rooms = append(rooms, map[string]interface{}{
|
||||||
|
"live_id": id, "connected": f.Ok(), "silent": f.IsSilent(),
|
||||||
|
"last_pkt_age": time.Since(time.Unix(int64(f.LastPkt()), 0)).Seconds(),
|
||||||
|
"chatter_count": len(f.Users()), "keywords": f.Keywords(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
return c.JSON(200, map[string]interface{}{"rooms": rooms})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRoomsDB(c echo.Context) error {
|
||||||
|
return c.JSON(200, map[string]interface{}{"rooms": db.GetRooms()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddRoom(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "无效的直播间ID"})
|
||||||
|
}
|
||||||
|
db.SaveRoom(lid, "")
|
||||||
|
if license.LicenseAllowed(false) {
|
||||||
|
startFetcher(lid, db.GetSetting("kw_"+lid), "add-room")
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "live_id": lid})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 消息 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleMessages(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
after := parseInt64(c.QueryParam("after_id"), 0)
|
||||||
|
limit := min(parseInt(c.QueryParam("limit"), 200), 500)
|
||||||
|
msgs, last := db.GetMessages(lid, after, limit)
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "after_id": last, "messages": msgs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 用户 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleUsers(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
fetcherMu.RLock()
|
||||||
|
f := fetchers[lid]
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
if f != nil {
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(f.Users()), "users": f.Users()})
|
||||||
|
}
|
||||||
|
users := db.GetUsers(lid)
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(users), "users": userMapList(users)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUsersDB(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
users := db.GetUsers(lid)
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(users), "users": userMapList(users)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 标记 / 已看 / 客户 ─────────────────────────────────────
|
||||||
|
|
||||||
|
func handleMarksGet(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "marks": db.GetMarks(lid)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarkSet(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Mark string `json:"mark"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" || body.UserID == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id/user_id"})
|
||||||
|
}
|
||||||
|
valid := map[string]bool{"blue": true, "red": true, "yellow": true}
|
||||||
|
mark := body.Mark
|
||||||
|
if mark != "" && !valid[mark] {
|
||||||
|
mark = ""
|
||||||
|
}
|
||||||
|
db.SetMark(lid, body.UserID, mark)
|
||||||
|
fetcherMu.RLock()
|
||||||
|
if f := fetchers[lid]; f != nil {
|
||||||
|
f.SetMark(body.UserID, mark)
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "user_id": body.UserID, "mark": mark})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarksClear(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
db.ClearMarks(lid)
|
||||||
|
fetcherMu.RLock()
|
||||||
|
if f := fetchers[lid]; f != nil {
|
||||||
|
f.ClearMarks()
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMsgSeen(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
MsgID int64 `json:"msg_id"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" || body.MsgID == 0 {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id/msg_id"})
|
||||||
|
}
|
||||||
|
n := db.SetMsgSeen(lid, body.MsgID, body.Seen)
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "updated": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMsgSeenBulk(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" || len(body.IDs) == 0 {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id/ids"})
|
||||||
|
}
|
||||||
|
n := db.SetMsgSeenMany(lid, body.IDs, body.Seen)
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "updated": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMsgSeenClear(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
n := db.ClearMsgSeen(lid)
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "cleared": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLead(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Lead bool `json:"lead"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" || body.UserID == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id/user_id"})
|
||||||
|
}
|
||||||
|
db.SetLead(lid, body.UserID, body.Lead)
|
||||||
|
fetcherMu.RLock()
|
||||||
|
if f := fetchers[lid]; f != nil {
|
||||||
|
f.SetLead(body.UserID, body.Lead)
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "lead": body.Lead})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLeads(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
leads := db.GetLeads(lid)
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "count": len(leads), "leads": userMapList(leads)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLeadsExport(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
leads := db.GetLeads(lid)
|
||||||
|
buf := new(strings.Builder)
|
||||||
|
w := csv.NewWriter(buf)
|
||||||
|
w.Write([]string{"live_id", "user_id", "nickname", "profile_url", "mark", "last_msg", "msg_count",
|
||||||
|
"gift_count", "enter_count", "like_count", "follow_count", "lead_time", "last_seen"})
|
||||||
|
for _, u := range leads {
|
||||||
|
profileURL := ""
|
||||||
|
if u.SecUID != "" {
|
||||||
|
profileURL = "https://www.douyin.com/user/" + u.SecUID
|
||||||
|
}
|
||||||
|
w.Write([]string{
|
||||||
|
u.LiveID, u.UserID, u.UserName, profileURL, u.Mark, u.LastMsg,
|
||||||
|
strconv.Itoa(u.MsgCount), strconv.Itoa(u.GiftCount), strconv.Itoa(u.EnterCount),
|
||||||
|
strconv.Itoa(u.LikeCount), strconv.Itoa(u.FollowCount),
|
||||||
|
formatTS(u.LeadTs), formatTS(u.LastSeen),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
resp := c.Response()
|
||||||
|
resp.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
resp.Header().Set("Content-Disposition", "attachment; filename=leads.csv")
|
||||||
|
resp.Write([]byte("\ufeff" + buf.String()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExport(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
fmtType := c.QueryParam("fmt")
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
fetcherMu.RLock()
|
||||||
|
var users []map[string]interface{}
|
||||||
|
if f := fetchers[lid]; f != nil {
|
||||||
|
users = f.Users()
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
if len(users) == 0 {
|
||||||
|
users = userMapList(db.GetUsers(lid))
|
||||||
|
}
|
||||||
|
if fmtType == "json" {
|
||||||
|
return c.JSON(200, users)
|
||||||
|
}
|
||||||
|
buf := new(strings.Builder)
|
||||||
|
w := csv.NewWriter(buf)
|
||||||
|
w.Write([]string{"user_id", "nickname", "msg_count", "gift_count", "enter_count", "like_count",
|
||||||
|
"follow_count", "last_msg", "hit_keyword", "mark", "lead", "last_action", "sec_uid", "profile_url"})
|
||||||
|
for _, u := range users {
|
||||||
|
suid := strVal(u, "sec_uid")
|
||||||
|
profileURL := ""
|
||||||
|
if suid != "" {
|
||||||
|
profileURL = "https://www.douyin.com/user/" + suid
|
||||||
|
}
|
||||||
|
w.Write([]string{
|
||||||
|
strVal(u, "user_id"), strVal(u, "user_name"),
|
||||||
|
strVal(u, "msg_count"), strVal(u, "gift_count"), strVal(u, "enter_count"),
|
||||||
|
strVal(u, "like_count"), strVal(u, "follow_count"),
|
||||||
|
strVal(u, "last_msg"), strVal(u, "hit_kw"), strVal(u, "mark"),
|
||||||
|
func() string { if boolVal(u, "lead") { return "精准客户" }; return "" }(),
|
||||||
|
strVal(u, "last_action"), suid, profileURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
resp := c.Response()
|
||||||
|
resp.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
resp.Header().Set("Content-Disposition", "attachment; filename=users_"+lid+".csv")
|
||||||
|
resp.Write([]byte("\ufeff" + buf.String()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 关键词 / 解析 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleKeywordsGet(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("live_id"))
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]interface{}{"live_id": lid, "keywords": db.GetSetting("kw_" + lid)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleKeywordsSet(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
Keywords string `json:"keywords"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
lid := resolveID(body.LiveID)
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "missing live_id"})
|
||||||
|
}
|
||||||
|
db.SetSetting("kw_"+lid, body.Keywords)
|
||||||
|
fetcherMu.RLock()
|
||||||
|
if f := fetchers[lid]; f != nil {
|
||||||
|
f.SetKeywords(body.Keywords)
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleResolve(c echo.Context) error {
|
||||||
|
lid := resolveID(c.QueryParam("input"))
|
||||||
|
if lid == "" {
|
||||||
|
return c.JSON(400, map[string]string{"error": "无法识别为抖音直播间,请输入直播间ID或完整分享链接"})
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]string{"live_id": lid})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 状态 / SSE ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleStatus(c echo.Context) error {
|
||||||
|
fetcherMu.RLock()
|
||||||
|
rooms := make([]map[string]interface{}, 0)
|
||||||
|
for id, f := range fetchers {
|
||||||
|
rooms = append(rooms, map[string]interface{}{
|
||||||
|
"live_id": id, "connected": f.Ok(),
|
||||||
|
"last_pkt_age": time.Since(time.Unix(int64(f.LastPkt()), 0)).Seconds(),
|
||||||
|
"silent": f.IsSilent(), "chatter_count": len(f.Users()), "keywords": f.Keywords(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fetcherMu.RUnlock()
|
||||||
|
ts := license.AppLicenseState(false)
|
||||||
|
note, _ := ts["note"].(string)
|
||||||
|
if note == "" {
|
||||||
|
note = "登录订阅账号以解锁全部功能"
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]interface{}{
|
||||||
|
"mode": func() string { if db.GetSetting("mode") == "debug" { return "debug" }; return "release" }(),
|
||||||
|
"version": "1.1.0",
|
||||||
|
"uptime": time.Since(startedAt).Seconds(),
|
||||||
|
"log_enabled": db.GetSetting("log_enabled") == "1",
|
||||||
|
"rooms": rooms,
|
||||||
|
"license": ts,
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStream(c echo.Context) error {
|
||||||
|
ch := bc.Register()
|
||||||
|
c.Response().Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Response().Header().Set("Connection", "keep-alive")
|
||||||
|
flusher, _ := c.Response().Writer.(http.Flusher)
|
||||||
|
done := c.Request().Context().Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
bc.Unregister(ch)
|
||||||
|
return nil
|
||||||
|
case data := <-ch:
|
||||||
|
fmt.Fprintf(c.Response().Writer, "data: %s\n\n", string(data))
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 错误日志 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleErrors(c echo.Context) error {
|
||||||
|
limit := parseInt(c.QueryParam("limit"), 100)
|
||||||
|
return c.JSON(200, map[string]interface{}{"errors": db.GetErrors(limit)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleError(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Stack string `json:"stack"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
msg := body.Message
|
||||||
|
if len(msg) > 2000 {
|
||||||
|
msg = msg[:2000]
|
||||||
|
}
|
||||||
|
stack := body.Stack
|
||||||
|
if len(stack) > 8000 {
|
||||||
|
stack = stack[:8000]
|
||||||
|
}
|
||||||
|
db.LogError("frontend", body.RoomID, msg, stack)
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 授权 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleLicenseGet(c echo.Context) error {
|
||||||
|
return c.JSON(200, map[string]interface{}{
|
||||||
|
"license": license.AppLicenseState(true),
|
||||||
|
"config": license.LicenseConfig(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLicensePost(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
ServerUrl string `json:"server_url"`
|
||||||
|
CustomerCode string `json:"customer_code"`
|
||||||
|
ProductCode string `json:"product_code"`
|
||||||
|
AccountToken string `json:"account_token"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
if body.Login != "" && body.Password != "" {
|
||||||
|
data, err := license.LoginAccount(body.ServerUrl, body.Login, body.Password)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(400, map[string]interface{}{"status": "error", "message": err.Error(), "license": license.AppLicenseState(false)})
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "account": data, "license": license.CheckCloudLicense(true), "config": license.LicenseConfig()})
|
||||||
|
}
|
||||||
|
license.SaveLicenseConfig(body.ServerUrl, body.CustomerCode, body.ProductCode, body.AccountToken)
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "license": license.CheckCloudLicense(true), "config": license.LicenseConfig()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLicenseLogout(c echo.Context) error {
|
||||||
|
license.ClearLicenseLogin()
|
||||||
|
return c.JSON(200, map[string]interface{}{"status": "ok", "license": license.AppLicenseState(true), "config": license.LicenseConfig()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 设置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleSettingsGet(c echo.Context) error {
|
||||||
|
return c.JSON(200, map[string]interface{}{
|
||||||
|
"log_enabled": db.GetSetting("log_enabled") == "1",
|
||||||
|
"mode": db.GetSetting("mode"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSettingsPost(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
LogEnabled *bool `json:"log_enabled"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
c.Bind(&body)
|
||||||
|
if body.LogEnabled != nil {
|
||||||
|
db.SetSetting("log_enabled", boolStr(*body.LogEnabled))
|
||||||
|
}
|
||||||
|
if body.Mode == "debug" || body.Mode == "release" {
|
||||||
|
db.SetSetting("mode", body.Mode)
|
||||||
|
}
|
||||||
|
return c.JSON(200, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 内部 helper ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func startFetcher(lid, keywords, reason string) {
|
||||||
|
fetcherMu.Lock()
|
||||||
|
defer fetcherMu.Unlock()
|
||||||
|
if f, ok := fetchers[lid]; ok {
|
||||||
|
if f.ThreadAlive() {
|
||||||
|
log.Printf("[api] room %s already running", lid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Stop()
|
||||||
|
delete(fetchers, lid)
|
||||||
|
}
|
||||||
|
if keywords == "" {
|
||||||
|
keywords = db.GetSetting("kw_" + lid)
|
||||||
|
}
|
||||||
|
log.Printf("[api] start room=%s kw=[%s] reason=%s", lid, keywords, reason)
|
||||||
|
bc.Broadcast(map[string]interface{}{"type": "status", "room_id": lid, "content": "connecting", "timestamp": float64(time.Now().Unix())})
|
||||||
|
f := fetcher.NewFetcher(lid, bc, keywords)
|
||||||
|
fetchers[lid] = f
|
||||||
|
go f.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopFetcher(lid string) {
|
||||||
|
fetcherMu.Lock()
|
||||||
|
f := fetchers[lid]
|
||||||
|
delete(fetchers, lid)
|
||||||
|
fetcherMu.Unlock()
|
||||||
|
if f != nil {
|
||||||
|
f.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _autoWatch() {
|
||||||
|
for {
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
if !license.LicenseAllowed(false) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rooms := db.GetRooms()
|
||||||
|
fetcherMu.Lock()
|
||||||
|
for _, r := range rooms {
|
||||||
|
lid := r.LiveID
|
||||||
|
f := fetchers[lid]
|
||||||
|
if f == nil {
|
||||||
|
startFetcher(lid, db.GetSetting("kw_"+lid), "auto-start")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alive := f.ThreadAlive()
|
||||||
|
ok := f.Ok()
|
||||||
|
age := time.Since(time.Unix(int64(f.LastPkt()), 0)).Seconds()
|
||||||
|
silent := f.IsSilent()
|
||||||
|
if alive && ok && !silent && age <= 90 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[auto-watch] room %s reconnect: age=%.1fs", lid, age)
|
||||||
|
stopFetcher(lid)
|
||||||
|
startFetcher(lid, db.GetSetting("kw_"+lid), "auto-reconnect")
|
||||||
|
}
|
||||||
|
for lid := range fetchers {
|
||||||
|
found := false
|
||||||
|
for _, r := range rooms {
|
||||||
|
if r.LiveID == lid {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
stopFetcher(lid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetcherMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _licenseWatch() {
|
||||||
|
for {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
if !license.LicenseAllowed(false) && len(fetchers) > 0 {
|
||||||
|
fetcherMu.Lock()
|
||||||
|
for id := range fetchers {
|
||||||
|
stopFetcher(id)
|
||||||
|
}
|
||||||
|
fetcherMu.Unlock()
|
||||||
|
log.Printf("[license] 授权不可用,全部直播间已停止")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveID(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if allDigits(raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
re1 := regexp.MustCompile(`live\.douyin\.com/(\d+)`)
|
||||||
|
if m := re1.FindStringSubmatch(raw); len(m) > 1 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
re2 := regexp.MustCompile(`webcast\.amemv\.com/\w+/\w+/reflow/(\d+)`)
|
||||||
|
if m := re2.FindStringSubmatch(raw); len(m) > 1 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
re3 := regexp.MustCompile(`https?://\S+`)
|
||||||
|
for _, u := range re3.FindAllString(raw, -1) {
|
||||||
|
if m := re1.FindStringSubmatch(u); len(m) > 1 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
if m := re2.FindStringSubmatch(u); len(m) > 1 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allDigits(raw) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDigits(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func userMapList(users []db.User) []map[string]interface{} {
|
||||||
|
out := make([]map[string]interface{}, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
out[i] = map[string]interface{}{
|
||||||
|
"id": u.ID, "live_id": u.LiveID, "user_id": u.UserID,
|
||||||
|
"user_name": u.UserName, "first_seen": u.FirstSeen, "last_seen": u.LastSeen,
|
||||||
|
"msg_count": u.MsgCount, "gift_count": u.GiftCount, "enter_count": u.EnterCount,
|
||||||
|
"like_count": u.LikeCount, "follow_count": u.FollowCount, "last_msg": u.LastMsg,
|
||||||
|
"last_action": u.LastAction, "hit_kw": u.HitKw, "sec_uid": u.SecUID,
|
||||||
|
"avatar": u.Avatar, "mark": u.Mark, "lead": u.Lead, "lead_ts": u.LeadTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func strVal(m map[string]interface{}, k string) string {
|
||||||
|
v, ok := m[k]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolVal(m map[string]interface{}, k string) bool {
|
||||||
|
v, ok := m[k]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b, _ := v.(bool)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string, def int) int {
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(s string, def int64) int64 {
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTS(ts float64) string {
|
||||||
|
if ts == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.Unix(int64(ts), 0).Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
66
internal/broadcaster/broadcaster.go
Normal file
66
internal/broadcaster/broadcaster.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package broadcaster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Broadcaster struct {
|
||||||
|
chs []chan []byte
|
||||||
|
lk sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBroadcaster() *Broadcaster {
|
||||||
|
return &Broadcaster{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *Broadcaster) Register() chan []byte {
|
||||||
|
ch := make(chan []byte, 1000)
|
||||||
|
bc.lk.Lock()
|
||||||
|
bc.chs = append(bc.chs, ch)
|
||||||
|
bc.lk.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *Broadcaster) Unregister(ch chan []byte) {
|
||||||
|
bc.lk.Lock()
|
||||||
|
defer bc.lk.Unlock()
|
||||||
|
for i, c := range bc.chs {
|
||||||
|
if c == ch {
|
||||||
|
bc.chs = append(bc.chs[:i], bc.chs[i+1:]...)
|
||||||
|
close(ch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *Broadcaster) Broadcast(data map[string]interface{}) {
|
||||||
|
b, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bc.lk.Lock()
|
||||||
|
dead := make([]int, 0)
|
||||||
|
for i, ch := range bc.chs {
|
||||||
|
select {
|
||||||
|
case ch <- b:
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
ch <- b
|
||||||
|
default:
|
||||||
|
dead = append(dead, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bc.lk.Unlock()
|
||||||
|
for j := len(dead) - 1; j >= 0; j-- {
|
||||||
|
bc.lk.Lock()
|
||||||
|
idx := dead[j]
|
||||||
|
if idx < len(bc.chs) {
|
||||||
|
close(bc.chs[idx])
|
||||||
|
bc.chs = append(bc.chs[:idx], bc.chs[idx+1:]...)
|
||||||
|
}
|
||||||
|
bc.lk.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
581
internal/db/db.go
Normal file
581
internal/db/db.go
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
dbPath string
|
||||||
|
mu sync.Mutex
|
||||||
|
sqlDB *sql.DB
|
||||||
|
)
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
Keywords string `json:"keywords"`
|
||||||
|
CreatedAt float64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
FirstSeen float64 `json:"first_seen"`
|
||||||
|
LastSeen float64 `json:"last_seen"`
|
||||||
|
MsgCount int `json:"msg_count"`
|
||||||
|
GiftCount int `json:"gift_count"`
|
||||||
|
EnterCount int `json:"enter_count"`
|
||||||
|
LikeCount int `json:"like_count"`
|
||||||
|
FollowCount int `json:"follow_count"`
|
||||||
|
LastMsg string `json:"last_msg"`
|
||||||
|
LastAction string `json:"last_action"`
|
||||||
|
HitKw string `json:"hit_kw"`
|
||||||
|
SecUID string `json:"sec_uid"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Mark string `json:"mark"`
|
||||||
|
Lead int `json:"lead"`
|
||||||
|
LeadTs float64 `json:"lead_ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
LiveID string `json:"live_id"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Seen int `json:"seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TS float64 `json:"ts"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Stack string `json:"stack"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
candidate := filepath.Join(filepath.Dir(exePath), "data.db")
|
||||||
|
if dir := filepath.Dir(candidate); writable(dir) {
|
||||||
|
dbPath = candidate
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appData := os.Getenv("APPDATA")
|
||||||
|
if appData == "" {
|
||||||
|
appData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Roaming")
|
||||||
|
}
|
||||||
|
dbPath = filepath.Join(appData, "青梧映星", "douyinlive", "data.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writable(d string) bool {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(d, ".wtest")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Remove(f.Name())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func conn() (*sql.DB, error) {
|
||||||
|
if sqlDB != nil {
|
||||||
|
return sqlDB, nil
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
sqlDB, err = sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(4)
|
||||||
|
sqlDB.SetMaxIdleConns(2)
|
||||||
|
return sqlDB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitDB() error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, err := conn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ensureMigrated(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMigrated(c *sql.DB) error {
|
||||||
|
_, err := c.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms(live_id TEXT PRIMARY KEY, keywords TEXT DEFAULT NULL, created_at REAL);
|
||||||
|
CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT, live_id TEXT, user_id TEXT, user_name TEXT, first_seen REAL, last_seen REAL, msg_count INTEGER DEFAULT 0, gift_count INTEGER DEFAULT 0, enter_count INTEGER DEFAULT 0, like_count INTEGER DEFAULT 0, follow_count INTEGER DEFAULT 0, last_msg TEXT, last_action TEXT, hit_kw TEXT, sec_uid TEXT DEFAULT '', avatar TEXT DEFAULT '', mark TEXT DEFAULT '', lead INTEGER DEFAULT 0, lead_ts REAL DEFAULT 0, UNIQUE(live_id, user_id));
|
||||||
|
CREATE TABLE IF NOT EXISTS error_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, source TEXT, room_id TEXT, message TEXT, stack TEXT);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_el ON error_logs(ts);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings(k TEXT PRIMARY KEY, v TEXT);
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, live_id TEXT, ts REAL, msg TEXT, seen INTEGER DEFAULT 0);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cl ON chat_logs(live_id, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ul ON users(live_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_un ON users(user_name);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return migrateUsers(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateUsers(c *sql.DB) error {
|
||||||
|
var cnt int
|
||||||
|
c.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'").Scan(&cnt)
|
||||||
|
if cnt == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for col, ddl := range map[string]string{
|
||||||
|
"sec_uid": "TEXT DEFAULT ''",
|
||||||
|
"avatar": "TEXT DEFAULT ''",
|
||||||
|
"mark": "TEXT DEFAULT ''",
|
||||||
|
"lead": "INTEGER DEFAULT 0",
|
||||||
|
"lead_ts": "REAL DEFAULT 0",
|
||||||
|
} {
|
||||||
|
var n int
|
||||||
|
c.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name=?", col).Scan(&n)
|
||||||
|
if n == 0 {
|
||||||
|
c.Exec(fmt.Sprintf("ALTER TABLE users ADD COLUMN %s %s", col, ddl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
c.QueryRow("SELECT COUNT(*) FROM pragma_table_info('chat_logs') WHERE name='seen'").Scan(&n)
|
||||||
|
if n == 0 {
|
||||||
|
c.Exec("ALTER TABLE chat_logs ADD COLUMN seen INTEGER DEFAULT 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRoom records a room (idempotent on live_id).
|
||||||
|
func SaveRoom(liveID, keywords string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
c.Exec("INSERT OR IGNORE INTO rooms(live_id, keywords, created_at) VALUES(?,?,?)",
|
||||||
|
liveID, keywords, float64(time.Now().Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRooms returns all rooms from DB.
|
||||||
|
func GetRooms() []Room {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
rows, err := c.Query("SELECT live_id, keywords, created_at FROM rooms ORDER BY created_at ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Room
|
||||||
|
for rows.Next() {
|
||||||
|
var r Room
|
||||||
|
rows.Scan(&r.LiveID, &r.Keywords, &r.CreatedAt)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelRoom removes a room and its associated users/chat_logs.
|
||||||
|
func DelRoom(liveID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
c.Exec("DELETE FROM users WHERE live_id=?", liveID)
|
||||||
|
c.Exec("DELETE FROM rooms WHERE live_id=?", liveID)
|
||||||
|
c.Exec("DELETE FROM chat_logs WHERE live_id=?", liveID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsers returns users for a room.
|
||||||
|
func GetUsers(liveID string) []User {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
rows, err := c.Query(
|
||||||
|
`SELECT id,live_id,user_id,user_name,first_seen,last_seen,
|
||||||
|
msg_count,gift_count,enter_count,like_count,follow_count,
|
||||||
|
last_msg,last_action,hit_kw,sec_uid,avatar,mark,lead,lead_ts
|
||||||
|
FROM users WHERE live_id=? ORDER BY (msg_count*10+gift_count*20+enter_count+like_count+follow_count) DESC`,
|
||||||
|
liveID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
rows.Scan(&u.ID, &u.LiveID, &u.UserID, &u.UserName, &u.FirstSeen, &u.LastSeen,
|
||||||
|
&u.MsgCount, &u.GiftCount, &u.EnterCount, &u.LikeCount, &u.FollowCount,
|
||||||
|
&u.LastMsg, &u.LastAction, &u.HitKw, &u.SecUID, &u.Avatar, &u.Mark, &u.Lead, &u.LeadTs)
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertUser creates or updates a user row.
|
||||||
|
func UpsertUser(liveID, uid, uname, action string, extra map[string]string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
now := float64(time.Now().Unix())
|
||||||
|
var id int64
|
||||||
|
var mc, gc, ec, lc, fc int
|
||||||
|
err := c.QueryRow("SELECT id,msg_count,gift_count,enter_count,like_count,follow_count FROM users WHERE live_id=? AND user_id=?", liveID, uid).Scan(&id, &mc, &gc, &ec, &lc, &fc)
|
||||||
|
if err == nil {
|
||||||
|
switch action {
|
||||||
|
case "chat":
|
||||||
|
mc++
|
||||||
|
case "gift":
|
||||||
|
gc++
|
||||||
|
case "enter":
|
||||||
|
ec++
|
||||||
|
case "like":
|
||||||
|
lc++
|
||||||
|
case "follow":
|
||||||
|
fc++
|
||||||
|
}
|
||||||
|
q := "'`"
|
||||||
|
sets := []string{fmt.Sprintf("last_seen=%g", now), fmt.Sprintf("last_action=%s%s%s", q, action, q)}
|
||||||
|
if action == "chat" {
|
||||||
|
cm := escapeSQL(extra["content"])
|
||||||
|
kw := escapeSQL(extra["keyword"])
|
||||||
|
sets = append(sets, fmt.Sprintf("msg_count=%d", mc))
|
||||||
|
sets = append(sets, fmt.Sprintf("last_msg=%s%s%s", q, cm, q))
|
||||||
|
sets = append(sets, fmt.Sprintf("hit_kw=%s%s%s", q, kw, q))
|
||||||
|
} else if action == "gift" {
|
||||||
|
sets = append(sets, fmt.Sprintf("gift_count=%d", gc))
|
||||||
|
} else if action == "enter" {
|
||||||
|
sets = append(sets, fmt.Sprintf("enter_count=%d", ec))
|
||||||
|
} else if action == "like" {
|
||||||
|
sets = append(sets, fmt.Sprintf("like_count=%d", lc))
|
||||||
|
} else if action == "follow" {
|
||||||
|
sets = append(sets, fmt.Sprintf("follow_count=%d", fc))
|
||||||
|
}
|
||||||
|
for _, col := range []string{"sec_uid", "avatar"} {
|
||||||
|
if v := extra[col]; v != "" {
|
||||||
|
sets = append(sets, fmt.Sprintf("%s=%s%s%s", col, q, escapeSQL(v), q))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Exec(fmt.Sprintf("UPDATE users SET %s WHERE id=%d", joinStrings(sets, ","), id))
|
||||||
|
} else {
|
||||||
|
cols := []string{"live_id", "user_id", "user_name", "first_seen", "last_seen", "last_action"}
|
||||||
|
vs := []interface{}{liveID, uid, uname, now, now, action}
|
||||||
|
for _, col := range []string{"sec_uid", "avatar"} {
|
||||||
|
if v := extra[col]; v != "" {
|
||||||
|
cols = append(cols, col)
|
||||||
|
vs = append(vs, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ps := make([]string, len(cols))
|
||||||
|
for i := range ps {
|
||||||
|
ps[i] = "?"
|
||||||
|
}
|
||||||
|
c.Exec(fmt.Sprintf("INSERT INTO users(%s) VALUES(%s)", joinStrings(cols, ","), joinStrings(ps, ",")), vs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeSQL(s string) string {
|
||||||
|
return `''` + s + `''`
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStrings(ss []string, sep string) string {
|
||||||
|
if len(ss) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := ss[0]
|
||||||
|
for i := 1; i < len(ss); i++ {
|
||||||
|
out += sep + ss[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMark assigns a color/mark to a user.
|
||||||
|
func SetMark(liveID, userID, mark string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
c.Exec("UPDATE users SET mark=? WHERE live_id=? AND user_id=?", mark, liveID, userID)
|
||||||
|
if mark != "" {
|
||||||
|
var id int64
|
||||||
|
c.QueryRow("SELECT id FROM users WHERE live_id=? AND user_id=?", liveID, userID).Scan(&id)
|
||||||
|
if id == 0 {
|
||||||
|
now := float64(time.Now().Unix())
|
||||||
|
c.Exec("INSERT OR IGNORE INTO users(live_id,user_id,user_name,first_seen,last_seen,mark) VALUES(?,?,?,?,?,?)",
|
||||||
|
liveID, userID, "", now, now, mark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearMarks resets all marks for a room.
|
||||||
|
func ClearMarks(liveID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
c.Exec("UPDATE users SET mark='' WHERE live_id=? AND mark!=''", liveID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMarks returns {user_id: mark} for a room.
|
||||||
|
func GetMarks(liveID string) map[string]string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
rows, err := c.Query("SELECT user_id, mark FROM users WHERE live_id=? AND mark IS NOT NULL AND mark!=''", liveID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var uid, m string
|
||||||
|
rows.Scan(&uid, &m)
|
||||||
|
out[uid] = m
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLead marks/unmarks a lead customer.
|
||||||
|
func SetLead(liveID, userID string, lead bool) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
now := float64(time.Now().Unix())
|
||||||
|
var ll int
|
||||||
|
var lts float64
|
||||||
|
if lead {
|
||||||
|
ll = 1
|
||||||
|
lts = now
|
||||||
|
}
|
||||||
|
c.Exec("UPDATE users SET lead=?, lead_ts=? WHERE live_id=? AND user_id=?", ll, lts, liveID, userID)
|
||||||
|
if lead {
|
||||||
|
var id int64
|
||||||
|
c.QueryRow("SELECT id FROM users WHERE live_id=? AND user_id=?", liveID, userID).Scan(&id)
|
||||||
|
if id == 0 {
|
||||||
|
c.Exec("INSERT OR IGNORE INTO users(live_id,user_id,user_name,first_seen,last_seen,lead,lead_ts) VALUES(?,?,?,?,?,?,?)",
|
||||||
|
liveID, userID, "", now, now, 1, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeads returns lead customers, optionally scoped to one room.
|
||||||
|
func GetLeads(liveID string) []User {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if liveID != "" {
|
||||||
|
rows, err = c.Query("SELECT id,live_id,user_id,user_name,first_seen,last_seen,msg_count,gift_count,enter_count,like_count,follow_count,last_msg,last_action,hit_kw,sec_uid,avatar,mark,lead,lead_ts FROM users WHERE live_id=? AND lead=1 ORDER BY lead_ts DESC", liveID)
|
||||||
|
} else {
|
||||||
|
rows, err = c.Query("SELECT id,live_id,user_id,user_name,first_seen,last_seen,msg_count,gift_count,enter_count,like_count,follow_count,last_msg,last_action,hit_kw,sec_uid,avatar,mark,lead,lead_ts FROM users WHERE lead=1 ORDER BY lead_ts DESC")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
rows.Scan(&u.ID, &u.LiveID, &u.UserID, &u.UserName, &u.FirstSeen, &u.LastSeen,
|
||||||
|
&u.MsgCount, &u.GiftCount, &u.EnterCount, &u.LikeCount, &u.FollowCount,
|
||||||
|
&u.LastMsg, &u.LastAction, &u.HitKw, &u.SecUID, &u.Avatar, &u.Mark, &u.Lead, &u.LeadTs)
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var storeTypes = map[string]bool{
|
||||||
|
"chat": true, "gift": true, "enter": true, "like": true,
|
||||||
|
"follow": true, "fansclub": true, "emoji_chat": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreMsg persists a user-interaction message.
|
||||||
|
func StoreMsg(liveID, msgType string, data map[string]interface{}) {
|
||||||
|
if !storeTypes[msgType] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
ts := 0.0
|
||||||
|
if v, ok := data["timestamp"]; ok {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
ts = x
|
||||||
|
case int:
|
||||||
|
ts = float64(x)
|
||||||
|
case int64:
|
||||||
|
ts = float64(x)
|
||||||
|
default:
|
||||||
|
ts = float64(time.Now().Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts == 0 {
|
||||||
|
ts = float64(time.Now().Unix())
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"type": msgType, "room_id": liveID, "timestamp": ts}
|
||||||
|
for k, v := range data {
|
||||||
|
if k != "type" && k != "room_id" && k != "timestamp" {
|
||||||
|
payload[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
c.Exec("INSERT INTO chat_logs(live_id, ts, msg) VALUES(?,?,?)", liveID, ts, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessages returns messages for a room with pagination.
|
||||||
|
func GetMessages(liveID string, afterID int64, limit int) ([]ChatLog, int64) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
rows, err := c.Query("SELECT id, msg, seen FROM chat_logs WHERE live_id=? AND id>? ORDER BY id ASC LIMIT ?", liveID, afterID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, afterID
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatLog
|
||||||
|
var lastID = afterID
|
||||||
|
for rows.Next() {
|
||||||
|
var cl ChatLog
|
||||||
|
rows.Scan(&cl.ID, &cl.Msg, &cl.Seen)
|
||||||
|
out = append(out, cl)
|
||||||
|
lastID = cl.ID
|
||||||
|
}
|
||||||
|
return out, lastID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMsgSeen marks a single message as seen.
|
||||||
|
func SetMsgSeen(liveID string, msgID int64, seen bool) int {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
v := 0
|
||||||
|
if seen {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
res, err := c.Exec("UPDATE chat_logs SET seen=? WHERE id=? AND live_id=?", v, msgID, liveID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMsgSeenMany bulk marks messages as seen.
|
||||||
|
func SetMsgSeenMany(liveID string, ids []int64, seen bool) int {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
if len(ids) > 2000 {
|
||||||
|
ids = ids[:2000]
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
vs := make([]interface{}, len(ids)+2)
|
||||||
|
vs[0] = btoi(seen)
|
||||||
|
vs[1] = liveID
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
vs[i+2] = id
|
||||||
|
}
|
||||||
|
res, err := c.Exec(fmt.Sprintf("UPDATE chat_logs SET seen=? WHERE live_id=? AND id IN (%s)", joinStrings(ph, ",")), vs...)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearMsgSeen resets all seen flags for a room.
|
||||||
|
func ClearMsgSeen(liveID string) int {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
res, err := c.Exec("UPDATE chat_logs SET seen=0 WHERE live_id=? AND seen=1", liveID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSetting reads a setting value.
|
||||||
|
func GetSetting(key string) string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
var v string
|
||||||
|
c.QueryRow("SELECT v FROM settings WHERE k=?", key).Scan(&v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSetting writes a setting value.
|
||||||
|
func SetSetting(key, val string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
c.Exec("INSERT OR REPLACE INTO settings(k,v) VALUES(?,?)", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogError stores an error log entry.
|
||||||
|
func LogError(source, roomID, message, stack string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
c.Exec("INSERT INTO error_logs(ts, source, room_id, message, stack) VALUES(?,?,?,?,?)",
|
||||||
|
float64(time.Now().Unix()),
|
||||||
|
truncStr(source, 20),
|
||||||
|
truncStr(roomID, 40),
|
||||||
|
truncStr(message, 2000),
|
||||||
|
truncStr(stack, 8000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetErrors returns recent error logs.
|
||||||
|
func GetErrors(limit int) []ErrorLog {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c, _ := conn()
|
||||||
|
rows, err := c.Query("SELECT id, ts, source, room_id, message, stack FROM error_logs ORDER BY id DESC LIMIT ?", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ErrorLog
|
||||||
|
for rows.Next() {
|
||||||
|
var el ErrorLog
|
||||||
|
rows.Scan(&el.ID, &el.TS, &el.Source, &el.RoomID, &el.Message, &el.Stack)
|
||||||
|
out = append(out, el)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncStr(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func btoi(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
714
internal/fetcher/fetcher.go
Normal file
714
internal/fetcher/fetcher.go
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
package fetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/broadcaster"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/db"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Fetcher struct {
|
||||||
|
LiveID string
|
||||||
|
bc *broadcaster.Broadcaster
|
||||||
|
ok atomic.Bool
|
||||||
|
ws atomic.Value
|
||||||
|
lastPkt int64
|
||||||
|
silent bool
|
||||||
|
kws string
|
||||||
|
kr *regexp.Regexp
|
||||||
|
users map[string]map[string]interface{}
|
||||||
|
lk sync.RWMutex
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var didC int64
|
||||||
|
|
||||||
|
func NewFetcher(liveID string, bc *broadcaster.Broadcaster, keywords string) *Fetcher {
|
||||||
|
var kr *regexp.Regexp
|
||||||
|
if keywords != "" {
|
||||||
|
parts := strings.FieldsFunc(keywords, func(c rune) bool { return c == ',' || c == ' ' || c == '\n' })
|
||||||
|
if len(parts) > 0 {
|
||||||
|
escaped := make([]string, len(parts))
|
||||||
|
for i, p := range parts {
|
||||||
|
escaped[i] = regexp.QuoteMeta(p)
|
||||||
|
}
|
||||||
|
kr = regexp.MustCompile("(?i)" + strings.Join(escaped, "|"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := &Fetcher{
|
||||||
|
LiveID: liveID,
|
||||||
|
bc: bc,
|
||||||
|
lastPkt: time.Now().Unix(),
|
||||||
|
kws: keywords,
|
||||||
|
kr: kr,
|
||||||
|
users: make(map[string]map[string]interface{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
for _, u := range db.GetUsers(liveID) {
|
||||||
|
f.users[u.UserID] = map[string]interface{}{
|
||||||
|
"user_id": u.UserID,
|
||||||
|
"user_name": u.UserName,
|
||||||
|
"msg_count": u.MsgCount,
|
||||||
|
"gift_count": u.GiftCount,
|
||||||
|
"enter_count": u.EnterCount,
|
||||||
|
"like_count": u.LikeCount,
|
||||||
|
"follow_count": u.FollowCount,
|
||||||
|
"last_msg": u.LastMsg,
|
||||||
|
"last_action": u.LastAction,
|
||||||
|
"hit_kw": u.HitKw,
|
||||||
|
"sec_uid": u.SecUID,
|
||||||
|
"avatar": u.Avatar,
|
||||||
|
"mark": u.Mark,
|
||||||
|
"lead": u.Lead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) Start() {
|
||||||
|
go f._connectWebSocket()
|
||||||
|
go f._watchdog()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) Stop() {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
close(f.done)
|
||||||
|
}
|
||||||
|
if ws, ok := f.ws.Load().(*websocket.Conn); ok && ws != nil {
|
||||||
|
cancel := func() {}
|
||||||
|
_ = cancel
|
||||||
|
ws.CloseNow()
|
||||||
|
}
|
||||||
|
f.ok.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) Ok() bool { return f.ok.Load() }
|
||||||
|
func (f *Fetcher) LastPkt() float64 { return float64(atomic.LoadInt64(&f.lastPkt)) }
|
||||||
|
func (f *Fetcher) IsSilent() bool { return f.silent }
|
||||||
|
func (f *Fetcher) Keywords() string { return f.kws }
|
||||||
|
func (f *Fetcher) SetKeywords(kws string) {
|
||||||
|
f.kws = kws
|
||||||
|
var kr *regexp.Regexp
|
||||||
|
if kws != "" {
|
||||||
|
parts := strings.FieldsFunc(kws, func(c rune) bool { return c == ',' || c == ' ' || c == '\n' })
|
||||||
|
if len(parts) > 0 {
|
||||||
|
escaped := make([]string, len(parts))
|
||||||
|
for i, p := range parts {
|
||||||
|
escaped[i] = regexp.QuoteMeta(p)
|
||||||
|
}
|
||||||
|
kr = regexp.MustCompile("(?i)" + strings.Join(escaped, "|"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.kr = kr
|
||||||
|
}
|
||||||
|
func (f *Fetcher) SetMark(uid, mark string) { db.SetMark(f.LiveID, uid, mark) }
|
||||||
|
func (f *Fetcher) ClearMarks() { db.ClearMarks(f.LiveID) }
|
||||||
|
func (f *Fetcher) SetLead(uid string, lead bool) { db.SetLead(f.LiveID, uid, lead) }
|
||||||
|
func (f *Fetcher) ThreadAlive() bool {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) Users() []map[string]interface{} {
|
||||||
|
f.lk.RLock()
|
||||||
|
defer f.lk.RUnlock()
|
||||||
|
out := make([]map[string]interface{}, 0, len(f.users))
|
||||||
|
for _, u := range f.users {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _connectWebSocket() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
ttwid := f.getTTWID()
|
||||||
|
roomID := f.getRoomID(ttwid)
|
||||||
|
if roomID == "" {
|
||||||
|
roomID = f.LiveID
|
||||||
|
}
|
||||||
|
nonce := f.getACNonce()
|
||||||
|
sig := sign.GetACSignature("www.douyin.com", nonce, uaStr, 0)
|
||||||
|
wssURL := f.buildWSSURL(roomID, sig, nonce, ttwid)
|
||||||
|
sig2, err := sign.GenerateSignature(wssURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[fetcher:%s] sign error: %v", f.LiveID, err)
|
||||||
|
}
|
||||||
|
wssURL += "&signature=" + sig2
|
||||||
|
f._wsLoop(wssURL)
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uaStr = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
func (f *Fetcher) getTTWID() string {
|
||||||
|
req, _ := http.NewRequest("GET", "https://live.douyin.com/"+f.LiveID, nil)
|
||||||
|
req.Header.Set("User-Agent", uaStr)
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "ttwid" {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) getRoomID(ttwid string) string {
|
||||||
|
req, _ := http.NewRequest("GET", "https://live.douyin.com/"+f.LiveID, nil)
|
||||||
|
req.Header.Set("User-Agent", uaStr)
|
||||||
|
req.Header.Set("Cookie", "ttwid="+ttwid+"; __ac_nonce=0123407cc00a9e438deb4")
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
m := regexp.MustCompile(`roomId[":\s]+(\d+)`).FindSubmatch(body)
|
||||||
|
if len(m) > 1 {
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
m = regexp.MustCompile(`"room_id_str":"(\d+)"`).FindSubmatch(body)
|
||||||
|
if len(m) > 1 {
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) getACNonce() string {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get("https://www.douyin.com/")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "__ac_nonce" {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) buildWSSURL(roomID, sig, nonce, ttwid string) string {
|
||||||
|
did := fmt.Sprintf("7%d", atomic.AddInt64(&didC, 1))
|
||||||
|
nowMs := time.Now().UnixMilli()
|
||||||
|
internalExt := fmt.Sprintf("internal_src:dim|wss_push_room_id:%s|wss_push_did:%s|first_req_ms:%d|fetch_time:%d|seq:1|wss_info:0-%d-0-0|wrds_v:%d",
|
||||||
|
roomID, did, nowMs, nowMs, nowMs, rand.Int63n(9999999999999))
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("app_name", "douyin_web")
|
||||||
|
params.Set("version_code", "180800")
|
||||||
|
params.Set("webcast_sdk_version", "1.0.14-beta.0")
|
||||||
|
params.Set("compress", "gzip")
|
||||||
|
params.Set("device_platform", "web")
|
||||||
|
params.Set("cookie_enabled", "true")
|
||||||
|
params.Set("screen_width", "1536")
|
||||||
|
params.Set("screen_height", "864")
|
||||||
|
params.Set("browser_language", "zh-CN")
|
||||||
|
params.Set("browser_platform", "Win32")
|
||||||
|
params.Set("browser_name", "Mozilla")
|
||||||
|
params.Set("browser_version", "5.0%20(Windows%20NT%2010.0;%20Win64;%20x64)%20AppleWebKit/537.36%20(KHTML,%20like%20Gecko)%20Chrome/126.0.0.0%20Safari/537.36")
|
||||||
|
params.Set("browser_online", "true")
|
||||||
|
params.Set("tz_name", "Asia/Shanghai")
|
||||||
|
params.Set("cursor", "d-1_u-1_fh-1_t-1_r-1")
|
||||||
|
params.Set("internal_ext", internalExt)
|
||||||
|
params.Set("host", "https://live.douyin.com")
|
||||||
|
params.Set("aid", "6383")
|
||||||
|
params.Set("live_id", "1")
|
||||||
|
params.Set("did_rule", "3")
|
||||||
|
params.Set("endpoint", "live_pc")
|
||||||
|
params.Set("support_wrds", "1")
|
||||||
|
params.Set("user_unique_id", did)
|
||||||
|
params.Set("im_path", "/webcast/im/fetch/")
|
||||||
|
params.Set("identity", "audience")
|
||||||
|
params.Set("need_persist_msg_count", "15")
|
||||||
|
params.Set("room_id", roomID)
|
||||||
|
params.Set("heartbeatDuration", "0")
|
||||||
|
base := "wss://webcast100-ws-web-lq.douyin.com/webcast/im/push/v2/"
|
||||||
|
return base + "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _wsLoop(wssURL string) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("User-Agent", uaStr)
|
||||||
|
headers.Set("Referer", "https://live.douyin.com/"+f.LiveID)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
ws, _, err := websocket.Dial(ctx, wssURL, &websocket.DialOptions{HTTPHeader: headers})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[fetcher:%s] ws dial error: %v", f.LiveID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.ws.Store(ws)
|
||||||
|
f.ok.Store(true)
|
||||||
|
atomic.StoreInt64(&f.lastPkt, time.Now().Unix())
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "status", "room_id": f.LiveID, "content": "connected"})
|
||||||
|
go f._heartbeat(ws)
|
||||||
|
go f._readLoop(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _heartbeat(ws *websocket.Conn) {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
pushFrame := buildPushFrame(1, []byte{})
|
||||||
|
if err := ws.Write(context.Background(), websocket.MessageBinary, pushFrame); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f._watchdogCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPushFrame(msgType byte, payload []byte) []byte {
|
||||||
|
length := uint32(16 + len(payload))
|
||||||
|
buf := make([]byte, length)
|
||||||
|
binary.BigEndian.PutUint32(buf[0:4], 2)
|
||||||
|
buf[4] = msgType
|
||||||
|
binary.BigEndian.PutUint32(buf[8:12], length)
|
||||||
|
binary.BigEndian.PutUint64(buf[12:16], uint64(time.Now().UnixNano()))
|
||||||
|
copy(buf[16:], payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _readLoop(ws *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
f.ok.Store(false)
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "status", "room_id": f.LiveID, "content": "disconnected"})
|
||||||
|
ws.Close(websocket.StatusNormalClosure, "")
|
||||||
|
}()
|
||||||
|
buf := make([]byte, 4*1024*1024)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
msgType, reader, err := ws.Reader(contextWithTimeout(30 * time.Second))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgType != websocket.MessageBinary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := io.ReadFull(reader, buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := buf[:n]
|
||||||
|
if len(data) < 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
atomic.StoreInt64(&f.lastPkt, time.Now().Unix())
|
||||||
|
f._watchdogCheck()
|
||||||
|
if err := f._dispatch(data); err != nil {
|
||||||
|
log.Printf("[fetcher:%s] dispatch error: %v", f.LiveID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _dispatch(data []byte) error {
|
||||||
|
if len(data) < 16 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
version := binary.BigEndian.Uint32(data[0:4])
|
||||||
|
msgTypeByte := data[4]
|
||||||
|
compressed := (msgTypeByte & 0x80) != 0
|
||||||
|
needsAck := (msgTypeByte & 0x40) != 0
|
||||||
|
msgType := msgTypeByte & 0x3F
|
||||||
|
length := binary.BigEndian.Uint32(data[8:12])
|
||||||
|
logID := binary.BigEndian.Uint64(data[12:16])
|
||||||
|
|
||||||
|
var payload []byte
|
||||||
|
offset := 16
|
||||||
|
if compressed {
|
||||||
|
switch msgType {
|
||||||
|
case 0:
|
||||||
|
decomp, err := decompressGzip(data[offset:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload = decomp
|
||||||
|
case 1:
|
||||||
|
payload = data[offset:]
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload = data[offset:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsAck && length > 0 {
|
||||||
|
ack := make([]byte, 16+len(payload))
|
||||||
|
binary.BigEndian.PutUint32(ack[0:4], version)
|
||||||
|
ack[4] = msgTypeByte | 0x40
|
||||||
|
binary.BigEndian.PutUint32(ack[8:12], uint32(len(payload)))
|
||||||
|
binary.BigEndian.PutUint64(ack[12:16], logID)
|
||||||
|
copy(ack[16:], payload)
|
||||||
|
if ws, ok := f.ws.Load().(*websocket.Conn); ok && ws != nil {
|
||||||
|
ws.Write(context.Background(), websocket.MessageBinary, ack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgs []dyMessage
|
||||||
|
if err := json.Unmarshal(payload, &msgs); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
switch msg.Method {
|
||||||
|
case "WebcastChatMessage":
|
||||||
|
f._parseChatMsg(msg.Payload)
|
||||||
|
case "WebcastGiftMessage":
|
||||||
|
f._parseGiftMsg(msg.Payload)
|
||||||
|
case "WebcastMemberMessage":
|
||||||
|
f._parseMemberMsg(msg.Payload)
|
||||||
|
case "WebcastLikeMessage":
|
||||||
|
f._parseLikeMsg(msg.Payload)
|
||||||
|
case "WebcastSocialMessage":
|
||||||
|
f._parseSocialMsg(msg.Payload)
|
||||||
|
case "WebcastFansclubMessage":
|
||||||
|
f._parseFansclubMsg(msg.Payload)
|
||||||
|
case "WebcastControlMessage":
|
||||||
|
f._parseControlMsg(msg.Payload)
|
||||||
|
case "WebcastEmojiChatMessage":
|
||||||
|
f._parseEmojiChatMsg(msg.Payload)
|
||||||
|
case "WebcastRoomUserSeqMessage":
|
||||||
|
f._parseRoomUserSeqMsg(msg.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dyMessage struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decompressGzip(data []byte) ([]byte, error) {
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseChatMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
User struct {
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
SecUID string `json:"secUid"`
|
||||||
|
AvatarThumb struct {
|
||||||
|
URLList []string `json:"urlList"`
|
||||||
|
} `json:"avatarThumb"`
|
||||||
|
} `json:"user"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
uid := fmt.Sprintf("%d", m.User.ID)
|
||||||
|
sec := m.User.SecUID
|
||||||
|
av := ""
|
||||||
|
if len(m.User.AvatarThumb.URLList) > 0 {
|
||||||
|
av = m.User.AvatarThumb.URLList[0]
|
||||||
|
}
|
||||||
|
kw := ""
|
||||||
|
if f.kr != nil && f.kr.MatchString(m.Content) {
|
||||||
|
kw = f.kr.FindString(m.Content)
|
||||||
|
}
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "chat", "room_id": f.LiveID, "user_name": m.User.NickName, "user_id": uid, "sec_uid": sec, "avatar": av, "content": m.Content, "keyword": kw})
|
||||||
|
db.StoreMsg(f.LiveID, "chat", map[string]interface{}{"type": "chat", "timestamp": float64(time.Now().Unix()), "user_name": m.User.NickName, "user_id": uid, "content": m.Content, "keyword": kw})
|
||||||
|
f._upsertUser(uid, m.User.NickName, "chat", map[string]string{"content": m.Content, "keyword": kw, "sec_uid": sec, "avatar": av})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseGiftMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
User struct {
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
SecUID string `json:"secUid"`
|
||||||
|
AvatarThumb struct {
|
||||||
|
URLList []string `json:"urlList"`
|
||||||
|
} `json:"avatarThumb"`
|
||||||
|
} `json:"user"`
|
||||||
|
Gift struct{ Name string `json:"name"` } `json:"gift"`
|
||||||
|
ComboCount int `json:"comboCount"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
uid := fmt.Sprintf("%d", m.User.ID)
|
||||||
|
sec, av := m.User.SecUID, ""
|
||||||
|
if len(m.User.AvatarThumb.URLList) > 0 {
|
||||||
|
av = m.User.AvatarThumb.URLList[0]
|
||||||
|
}
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "gift", "room_id": f.LiveID, "user_name": m.User.NickName, "user_id": uid, "sec_uid": sec, "avatar": av, "gift_name": m.Gift.Name, "count": m.ComboCount})
|
||||||
|
db.StoreMsg(f.LiveID, "gift", map[string]interface{}{"type": "gift", "timestamp": float64(time.Now().Unix()), "user_name": m.User.NickName, "user_id": uid, "gift_name": m.Gift.Name, "count": m.ComboCount})
|
||||||
|
f._upsertUser(uid, m.User.NickName, "gift", map[string]string{"sec_uid": sec, "avatar": av})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseMemberMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
User struct {
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Gender int `json:"gender"`
|
||||||
|
SecUID string `json:"secUid"`
|
||||||
|
AvatarThumb struct {
|
||||||
|
URLList []string `json:"urlList"`
|
||||||
|
} `json:"avatarThumb"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
uid := fmt.Sprintf("%d", m.User.ID)
|
||||||
|
sec, av := m.User.SecUID, ""
|
||||||
|
if len(m.User.AvatarThumb.URLList) > 0 {
|
||||||
|
av = m.User.AvatarThumb.URLList[0]
|
||||||
|
}
|
||||||
|
gender := "?"
|
||||||
|
if m.User.Gender == 0 {
|
||||||
|
gender = "女"
|
||||||
|
} else if m.User.Gender == 1 {
|
||||||
|
gender = "男"
|
||||||
|
}
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "enter", "room_id": f.LiveID, "user_name": m.User.NickName, "user_id": uid, "gender": gender, "sec_uid": sec, "avatar": av})
|
||||||
|
db.StoreMsg(f.LiveID, "enter", map[string]interface{}{"type": "enter", "timestamp": float64(time.Now().Unix()), "user_name": m.User.NickName, "user_id": uid, "gender": gender})
|
||||||
|
f._upsertUser(uid, m.User.NickName, "enter", map[string]string{"sec_uid": sec, "avatar": av})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseLikeMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
User struct {
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
SecUID string `json:"secUid"`
|
||||||
|
AvatarThumb struct {
|
||||||
|
URLList []string `json:"urlList"`
|
||||||
|
} `json:"avatarThumb"`
|
||||||
|
} `json:"user"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
uid := fmt.Sprintf("%d", m.User.ID)
|
||||||
|
sec, av := m.User.SecUID, ""
|
||||||
|
if len(m.User.AvatarThumb.URLList) > 0 {
|
||||||
|
av = m.User.AvatarThumb.URLList[0]
|
||||||
|
}
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "like", "room_id": f.LiveID, "user_name": m.User.NickName, "user_id": uid, "sec_uid": sec, "avatar": av, "count": m.Count})
|
||||||
|
db.StoreMsg(f.LiveID, "like", map[string]interface{}{"type": "like", "timestamp": float64(time.Now().Unix()), "user_name": m.User.NickName, "user_id": uid, "count": m.Count})
|
||||||
|
f._upsertUser(uid, m.User.NickName, "like", map[string]string{"sec_uid": sec, "avatar": av})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseSocialMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
User struct {
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
SecUID string `json:"secUid"`
|
||||||
|
AvatarThumb struct {
|
||||||
|
URLList []string `json:"urlList"`
|
||||||
|
} `json:"avatarThumb"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
uid := fmt.Sprintf("%d", m.User.ID)
|
||||||
|
sec, av := m.User.SecUID, ""
|
||||||
|
if len(m.User.AvatarThumb.URLList) > 0 {
|
||||||
|
av = m.User.AvatarThumb.URLList[0]
|
||||||
|
}
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "follow", "room_id": f.LiveID, "user_name": m.User.NickName, "user_id": uid, "sec_uid": sec, "avatar": av})
|
||||||
|
db.StoreMsg(f.LiveID, "follow", map[string]interface{}{"type": "follow", "timestamp": float64(time.Now().Unix()), "user_name": m.User.NickName, "user_id": uid})
|
||||||
|
f._upsertUser(uid, m.User.NickName, "follow", map[string]string{"sec_uid": sec, "avatar": av})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseFansclubMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
User struct {
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
SecUID string `json:"secUid"`
|
||||||
|
AvatarThumb struct {
|
||||||
|
URLList []string `json:"urlList"`
|
||||||
|
} `json:"avatarThumb"`
|
||||||
|
} `json:"user"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
uid := fmt.Sprintf("%d", m.User.ID)
|
||||||
|
sec, av := m.User.SecUID, ""
|
||||||
|
if len(m.User.AvatarThumb.URLList) > 0 {
|
||||||
|
av = m.User.AvatarThumb.URLList[0]
|
||||||
|
}
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "fansclub", "room_id": f.LiveID, "content": m.Content, "user_name": m.User.NickName, "user_id": uid, "sec_uid": sec, "avatar": av, "fc_type": m.Type})
|
||||||
|
f._upsertUser(uid, m.User.NickName, "follow", map[string]string{"sec_uid": sec, "avatar": av})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseControlMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
if m.Status == 3 {
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "status", "room_id": f.LiveID, "content": "stream_ended"})
|
||||||
|
go f.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseEmojiChatMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
EmojiID int `json:"emojiId"`
|
||||||
|
DefaultContent string `json:"defaultContent"`
|
||||||
|
User struct{ NickName string `json:"nickName"` } `json:"user"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "emoji_chat", "room_id": f.LiveID, "emoji_id": m.EmojiID, "user_name": m.User.NickName, "default_content": m.DefaultContent})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _parseRoomUserSeqMsg(payload []byte) {
|
||||||
|
var m struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
TotalPvForAnchor int `json:"totalPvForAnchor"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(payload, &m)
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "stats", "room_id": f.LiveID, "current_viewers": m.Total, "total_viewers": m.TotalPvForAnchor})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _upsertUser(uid, uname, action string, extra map[string]string) {
|
||||||
|
db.UpsertUser(f.LiveID, uid, uname, action, extra)
|
||||||
|
f.lk.Lock()
|
||||||
|
defer f.lk.Unlock()
|
||||||
|
if _, ok := f.users[uid]; !ok {
|
||||||
|
f.users[uid] = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
u := f.users[uid]
|
||||||
|
u["user_id"] = uid
|
||||||
|
u["user_name"] = uname
|
||||||
|
u["last_action"] = action
|
||||||
|
u["last_seen"] = float64(time.Now().Unix())
|
||||||
|
if v, ok := extra["content"]; ok && v != "" {
|
||||||
|
u["msg_count"] = asInt(u["msg_count"]) + 1
|
||||||
|
u["last_msg"] = v
|
||||||
|
}
|
||||||
|
if v, ok := extra["keyword"]; ok && v != "" {
|
||||||
|
u["hit_kw"] = v
|
||||||
|
}
|
||||||
|
if v, ok := extra["sec_uid"]; ok && v != "" {
|
||||||
|
u["sec_uid"] = v
|
||||||
|
}
|
||||||
|
if v, ok := extra["avatar"]; ok && v != "" {
|
||||||
|
u["avatar"] = v
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "gift":
|
||||||
|
u["gift_count"] = asInt(u["gift_count"]) + 1
|
||||||
|
case "enter":
|
||||||
|
u["enter_count"] = asInt(u["enter_count"]) + 1
|
||||||
|
case "like":
|
||||||
|
u["like_count"] = asInt(u["like_count"]) + 1
|
||||||
|
case "follow":
|
||||||
|
u["follow_count"] = asInt(u["follow_count"]) + 1
|
||||||
|
}
|
||||||
|
f.users[uid] = u
|
||||||
|
}
|
||||||
|
|
||||||
|
func asInt(v interface{}) int {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int:
|
||||||
|
return x
|
||||||
|
case int64:
|
||||||
|
return int(x)
|
||||||
|
case float64:
|
||||||
|
return int(x)
|
||||||
|
case nil:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _watchdog() {
|
||||||
|
tick := time.NewTicker(1 * time.Second)
|
||||||
|
stTick := time.NewTicker(25 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
defer stTick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
f._watchdogCheck()
|
||||||
|
case <-stTick.C:
|
||||||
|
if f.ok.Load() && !f.silent {
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "status", "room_id": f.LiveID, "content": "connected"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) _watchdogCheck() {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
dt := now - atomic.LoadInt64(&f.lastPkt)
|
||||||
|
if !f.ok.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dt > 60 && !f.silent {
|
||||||
|
f.silent = true
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "status", "room_id": f.LiveID, "content": "stream_ended"})
|
||||||
|
log.Printf("[fetcher:%s] no data for %.0fs, stream_ended", f.LiveID, float64(dt))
|
||||||
|
} else if dt <= 40 && f.silent {
|
||||||
|
f.silent = false
|
||||||
|
f.bc.Broadcast(map[string]interface{}{"type": "status", "room_id": f.LiveID, "content": "connected"})
|
||||||
|
log.Printf("[fetcher:%s] data resumed, connected", f.LiveID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextWithTimeout(d time.Duration) context.Context {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
343
internal/license/license.go
Normal file
343
internal/license/license.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultServer = "http://42.193.21.75:7777"
|
||||||
|
DefaultProductCode = "douyin-live-fetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyServerURL = "license_server_url"
|
||||||
|
keyAccountToken = "license_account_token"
|
||||||
|
keyCustomerCode = "license_customer_code"
|
||||||
|
keyProductCode = "license_product_code"
|
||||||
|
keySessionToken = "license_session_token"
|
||||||
|
keyLastCheckAt = "license_last_check_at"
|
||||||
|
keyLastAllowed = "license_last_allowed"
|
||||||
|
keyLastStatus = "license_last_status"
|
||||||
|
keyCustomerName = "license_customer_name"
|
||||||
|
keyProductName = "license_product_name"
|
||||||
|
keyStartAt = "license_start_at"
|
||||||
|
keyEndAt = "license_end_at"
|
||||||
|
keyPaymentOpts = "license_payment_options"
|
||||||
|
keyDeviceID = "device_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
const checkInterval = 300
|
||||||
|
|
||||||
|
func LicenseConfig() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"server_url": strings.TrimRight(funcWithDefault(keyServerURL, DefaultServer), "/"),
|
||||||
|
"account_token": strings.TrimSpace(funcWithDefault(keyAccountToken, "")),
|
||||||
|
"customer_code": strings.TrimSpace(funcWithDefault(keyCustomerCode, "")),
|
||||||
|
"product_code": strings.TrimSpace(funcWithDefault(keyProductCode, DefaultProductCode)),
|
||||||
|
"session_token": strings.TrimSpace(funcWithDefault(keySessionToken, "")),
|
||||||
|
"device_id": DeviceID(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveLicenseConfig(server, customerCode, productCode, accountToken string) {
|
||||||
|
db.SetSetting(keyServerURL, strings.TrimRight(server, "/"))
|
||||||
|
db.SetSetting(keyCustomerCode, strings.TrimSpace(customerCode))
|
||||||
|
db.SetSetting(keyProductCode, strings.TrimSpace(productCode))
|
||||||
|
db.SetSetting(keyAccountToken, strings.TrimSpace(accountToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearLicenseLogin() {
|
||||||
|
for _, k := range []string{
|
||||||
|
keyAccountToken, keyCustomerCode, keySessionToken,
|
||||||
|
keyLastCheckAt, keyLastAllowed, keyLastStatus,
|
||||||
|
keyCustomerName, keyProductName, keyStartAt, keyEndAt, keyPaymentOpts,
|
||||||
|
} {
|
||||||
|
db.SetSetting(k, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoginAccount(server, login, password string) (map[string]interface{}, error) {
|
||||||
|
base := strings.TrimRight(server, "/")
|
||||||
|
payload, _ := json.Marshal(map[string]string{"login": login, "password": password})
|
||||||
|
resp, err := http.Post(base+"/api/yser/public/auth/login", "application/json", io.NopCloser(strings.NewReader(string(payload))))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.Unmarshal(body, &result)
|
||||||
|
if code, _ := result["code"].(float64); code != 0 {
|
||||||
|
return nil, fmt.Errorf("%v", result["message"])
|
||||||
|
}
|
||||||
|
data, _ := result["data"].(map[string]interface{})
|
||||||
|
token, _ := data["api_token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("login success but no token returned")
|
||||||
|
}
|
||||||
|
customer, _ := data["customer"].(map[string]interface{})
|
||||||
|
customerCode, _ := customer["code"].(string)
|
||||||
|
if customerCode == "" {
|
||||||
|
if vc, ok := data["customer_code"].(string); ok {
|
||||||
|
customerCode = vc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if customerCode == "" {
|
||||||
|
return nil, fmt.Errorf("login success but no customer code returned")
|
||||||
|
}
|
||||||
|
cfg := LicenseConfig()
|
||||||
|
SaveLicenseConfig(base, customerCode, cfg["product_code"], token)
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckCloudLicense(force bool) map[string]interface{} {
|
||||||
|
cfg := LicenseConfig()
|
||||||
|
if cfg["account_token"] == "" && cfg["customer_code"] == "" {
|
||||||
|
return map[string]interface{}{"configured": false, "allowed": true, "state": "unconfigured", "note": "登录订阅账号以解锁全部功能"}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
lastAt := int64(0)
|
||||||
|
if v := funcWithDefault(keyLastCheckAt, "0"); v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &lastAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached := buildCached(cfg, lastAt)
|
||||||
|
if !force && lastAt > 0 && now-lastAt < checkInterval {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := "/api/yser/license/check"
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"product_code": cfg["product_code"],
|
||||||
|
"device_id": cfg["device_id"],
|
||||||
|
}
|
||||||
|
if cfg["account_token"] != "" {
|
||||||
|
endpoint = "/api/yser/account/license/check"
|
||||||
|
payload["account_token"] = cfg["account_token"]
|
||||||
|
payload["device_name"] = getHostname()
|
||||||
|
payload["fingerprint"] = runtime.GOOS + "/" + runtime.GOARCH
|
||||||
|
payload["session_token"] = cfg["session_token"]
|
||||||
|
} else {
|
||||||
|
payload["customer_code"] = cfg["customer_code"]
|
||||||
|
payload["hostname"] = getHostname()
|
||||||
|
payload["platform"] = runtime.GOOS + "/" + runtime.GOARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", cfg["server_url"]+endpoint, strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "DouyinLiveFetcher/1.0.0")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
endTS := parseTS(cached["end_at"].(string))
|
||||||
|
if cached["allowed"].(bool) && (endTS == 0 || endTS > now) {
|
||||||
|
cached["note"] = "暂时无法连接授权服务器,使用上次有效授权"
|
||||||
|
cached["offline"] = true
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cached["allowed"] = false
|
||||||
|
cached["state"] = "offline"
|
||||||
|
cached["note"] = "授权服务器连接失<E68EA5>? " + err.Error()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
var r map[string]interface{}
|
||||||
|
json.Unmarshal(respBody, &r)
|
||||||
|
|
||||||
|
info, _ := r["data"].(map[string]interface{})
|
||||||
|
allowed := info["allowed"] == true || info["allowed"] == 1
|
||||||
|
stateVal, _ := info["status"].(string)
|
||||||
|
if stateVal == "" {
|
||||||
|
if allowed {
|
||||||
|
stateVal = "active"
|
||||||
|
} else {
|
||||||
|
stateVal = "suspended"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetSetting(keyLastCheckAt, fmt.Sprintf("%d", now))
|
||||||
|
db.SetSetting(keyLastAllowed, fmt.Sprintf("%d", btoi(allowed)))
|
||||||
|
db.SetSetting(keyLastStatus, stateVal)
|
||||||
|
if v, ok := info["customer_name"].(string); ok {
|
||||||
|
db.SetSetting(keyCustomerName, v)
|
||||||
|
}
|
||||||
|
if v, ok := info["product_name"].(string); ok {
|
||||||
|
db.SetSetting(keyProductName, v)
|
||||||
|
}
|
||||||
|
if v, ok := info["start_at"].(string); ok {
|
||||||
|
db.SetSetting(keyStartAt, v)
|
||||||
|
}
|
||||||
|
if v, ok := info["end_at"].(string); ok {
|
||||||
|
db.SetSetting(keyEndAt, v)
|
||||||
|
}
|
||||||
|
if opts, ok := info["payment_options"].([]interface{}); ok {
|
||||||
|
ob, _ := json.Marshal(opts)
|
||||||
|
db.SetSetting(keyPaymentOpts, string(ob))
|
||||||
|
}
|
||||||
|
if v, ok := info["session_token"].(string); ok && v != "" {
|
||||||
|
db.SetSetting(keySessionToken, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached["allowed"] = allowed
|
||||||
|
cached["state"] = stateVal
|
||||||
|
cached["checked_at"] = now
|
||||||
|
if v, ok := info["customer_name"].(string); ok {
|
||||||
|
cached["customer_name"] = v
|
||||||
|
}
|
||||||
|
if v, ok := info["product_name"].(string); ok {
|
||||||
|
cached["product_name"] = v
|
||||||
|
}
|
||||||
|
if v, ok := info["start_at"].(string); ok {
|
||||||
|
cached["start_at"] = v
|
||||||
|
}
|
||||||
|
if v, ok := info["end_at"].(string); ok {
|
||||||
|
cached["end_at"] = v
|
||||||
|
}
|
||||||
|
if opts, ok := info["payment_options"].([]interface{}); ok {
|
||||||
|
cached["payment_options"] = opts
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
cached["note"] = "云授权有效"
|
||||||
|
} else {
|
||||||
|
if reason, ok := info["reason"].(string); ok {
|
||||||
|
cached["note"] = reason
|
||||||
|
} else {
|
||||||
|
cached["note"] = "云授权不可用: " + stateVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppLicenseState(force bool) map[string]interface{} {
|
||||||
|
cloud := CheckCloudLicense(force)
|
||||||
|
ts := trialState()
|
||||||
|
if configured, _ := cloud["configured"].(bool); configured {
|
||||||
|
if allowed, _ := cloud["allowed"].(bool); allowed {
|
||||||
|
cloud["state"] = "active"
|
||||||
|
cloud["days_left"] = nil
|
||||||
|
return cloud
|
||||||
|
}
|
||||||
|
cloud["days_left"] = 0
|
||||||
|
return cloud
|
||||||
|
}
|
||||||
|
ts["configured"] = false
|
||||||
|
if ts["state"] == "unconfigured" || (ts["state"] == "expired" && ts["days_left"] == 0) {
|
||||||
|
ts["allowed"] = true
|
||||||
|
} else {
|
||||||
|
ts["allowed"] = ts["state"] != "expired"
|
||||||
|
}
|
||||||
|
ts["server_url"] = DefaultServer
|
||||||
|
ts["product_code"] = DefaultProductCode
|
||||||
|
ts["device_id"] = DeviceID()
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func LicenseAllowed(force bool) bool {
|
||||||
|
s := AppLicenseState(force)
|
||||||
|
allowed, _ := s["allowed"].(bool)
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeviceID() string {
|
||||||
|
did := funcWithDefault(keyDeviceID, "")
|
||||||
|
if did != "" {
|
||||||
|
return did
|
||||||
|
}
|
||||||
|
mac := getFirstMac()
|
||||||
|
raw := fmt.Sprintf("%s|%s|%s", mac, getHostname(), runtime.GOOS+"/"+runtime.GOARCH)
|
||||||
|
did = uuid.NewSHA1(uuid.NameSpaceDNS, []byte(raw)).String()
|
||||||
|
db.SetSetting(keyDeviceID, did)
|
||||||
|
return did
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCached(cfg map[string]string, lastAt int64) map[string]interface{} {
|
||||||
|
popts := []interface{}{}
|
||||||
|
if raw := funcWithDefault(keyPaymentOpts, "[]"); raw != "[]" {
|
||||||
|
json.Unmarshal([]byte(raw), &popts)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"configured": true,
|
||||||
|
"allowed": funcWithDefault(keyLastAllowed, "0") == "1",
|
||||||
|
"state": funcWithDefault(keyLastStatus, "unknown"),
|
||||||
|
"account_token": cfg["account_token"],
|
||||||
|
"customer_code": cfg["customer_code"],
|
||||||
|
"product_code": cfg["product_code"],
|
||||||
|
"customer_name": funcWithDefault(keyCustomerName, ""),
|
||||||
|
"product_name": funcWithDefault(keyProductName, ""),
|
||||||
|
"start_at": funcWithDefault(keyStartAt, ""),
|
||||||
|
"end_at": funcWithDefault(keyEndAt, ""),
|
||||||
|
"server_url": cfg["server_url"],
|
||||||
|
"device_id": cfg["device_id"],
|
||||||
|
"checked_at": lastAt,
|
||||||
|
"payment_options": popts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trialState() map[string]interface{} {
|
||||||
|
if funcWithDefault("license", "") == "activated" {
|
||||||
|
return map[string]interface{}{"state": "activated", "days_left": nil, "configured": false, "allowed": true}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"state": "unconfigured", "days_left": nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFirstMac() string {
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.HardwareAddr != nil && len(iface.HardwareAddr) >= 6 {
|
||||||
|
return strings.ReplaceAll(iface.HardwareAddr.String(), ":", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHostname() string {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTS(s string) int64 {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, s[:19])
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
func btoi(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func strconvParseInt(s string) int64 {
|
||||||
|
var n int64
|
||||||
|
fmt.Sscanf(s, "%d", &n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func funcWithDefault(key, def string) string {
|
||||||
|
return db.GetSetting(key)
|
||||||
|
}
|
||||||
389
internal/sign/a_bogus.js
Normal file
389
internal/sign/a_bogus.js
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
function get_ab(dpf, ua) {
|
||||||
|
function enc_sum(n_str) {
|
||||||
|
function ir(t) {
|
||||||
|
return ir = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (t) {
|
||||||
|
return typeof t
|
||||||
|
} : function (t) {
|
||||||
|
return t && "function" == typeof Symbol && t.constructor === Symbol && t !== Symbol.prototype ? "symbol" : typeof t
|
||||||
|
},
|
||||||
|
ir(t)
|
||||||
|
}
|
||||||
|
function ur(t, r) {
|
||||||
|
for (var e = 0; e < r.length; e++) {
|
||||||
|
var n = r[e];
|
||||||
|
n.enumerable = n.enumerable || !1,
|
||||||
|
n.configurable = !0, "value" in n && (n.writable = !0),
|
||||||
|
Object.defineProperty(t, sr(n.key), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sr(t) {
|
||||||
|
var r = function (t, r) {
|
||||||
|
if ("object" != ir(t) || !t) return t;
|
||||||
|
var e = t[Symbol.toPrimitive];
|
||||||
|
if (void 0 !== e) {
|
||||||
|
var n = e.call(t, r || "default");
|
||||||
|
if ("object" != ir(n)) return n;
|
||||||
|
throw new TypeError("@@toPrimitive must return a primitive value.")
|
||||||
|
}
|
||||||
|
return ("string" === r ? String : Number)(t)
|
||||||
|
}(t, "string");
|
||||||
|
return "symbol" == ir(r) ? r : r + ""
|
||||||
|
}
|
||||||
|
var gr = function () {
|
||||||
|
function t() {
|
||||||
|
if (function (t, r) {
|
||||||
|
if (!(t instanceof r)) throw new TypeError("Cannot call a class as a function")
|
||||||
|
}(this, t), !(this instanceof t)) return new t;
|
||||||
|
this.reg = new Array(8),
|
||||||
|
this.chunk = [],
|
||||||
|
this.size = 0,
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
return function (t, r, e) {
|
||||||
|
r && ur(t.prototype, r),
|
||||||
|
e && ur(t, e),
|
||||||
|
Object.defineProperty(t, "prototype", {
|
||||||
|
writable: !1
|
||||||
|
})
|
||||||
|
}(t, [{
|
||||||
|
key: "reset",
|
||||||
|
value: function () {
|
||||||
|
this.reg[0] = 1937774191,
|
||||||
|
this.reg[1] = 1226093241,
|
||||||
|
this.reg[2] = 388252375,
|
||||||
|
this.reg[3] = 3666478592,
|
||||||
|
this.reg[4] = 2842636476,
|
||||||
|
this.reg[5] = 372324522,
|
||||||
|
this.reg[6] = 3817729613,
|
||||||
|
this.reg[7] = 2969243214,
|
||||||
|
this.chunk = [],
|
||||||
|
this.size = 0
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "write",
|
||||||
|
value: function (t) {
|
||||||
|
var r = "string" == typeof t ? function (t) {
|
||||||
|
var r = encodeURIComponent(t).replace(/%([0-9A-F]{2})/g, (function (t, r) {
|
||||||
|
return String.fromCharCode("0x" + r)
|
||||||
|
})),
|
||||||
|
e = new Array(r.length);
|
||||||
|
return Array.prototype.forEach.call(r, (function (t, r) {
|
||||||
|
e[r] = t.charCodeAt(0)
|
||||||
|
})),
|
||||||
|
e
|
||||||
|
}(t) : t;
|
||||||
|
this.size += r.length;
|
||||||
|
var e = 64 - this.chunk.length;
|
||||||
|
if (r.length < e) this.chunk = this.chunk.concat(r);
|
||||||
|
else
|
||||||
|
for (this.chunk = this.chunk.concat(r.slice(0, e)); this.chunk.length >= 64;) this._compress(this.chunk),
|
||||||
|
e < r.length ? this.chunk = r.slice(e, Math.min(e + 64, r.length)) : this.chunk = [],
|
||||||
|
e += 64
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "sum",
|
||||||
|
value: function (t, r) {
|
||||||
|
t && (this.reset(), this.write(t)),
|
||||||
|
this._fill();
|
||||||
|
for (var e = 0; e < this.chunk.length; e += 64) this._compress(this.chunk.slice(e, e + 64));
|
||||||
|
var n, o, i, u = null;
|
||||||
|
if ("hex" == r) {
|
||||||
|
u = "";
|
||||||
|
for (e = 0; e < 8; e++) u += (n = this.reg[e].toString(16), o = 8, i = "0", n.length >= o ? n : i.repeat(o - n.length) + n)
|
||||||
|
} else
|
||||||
|
for (u = new Array(32), e = 0; e < 8; e++) {
|
||||||
|
var s = this.reg[e];
|
||||||
|
u[4 * e + 3] = (255 & s) >>> 0,
|
||||||
|
s >>>= 8,
|
||||||
|
u[4 * e + 2] = (255 & s) >>> 0,
|
||||||
|
s >>>= 8,
|
||||||
|
u[4 * e + 1] = (255 & s) >>> 0,
|
||||||
|
s >>>= 8,
|
||||||
|
u[4 * e] = (255 & s) >>> 0
|
||||||
|
}
|
||||||
|
return this.reset(),
|
||||||
|
u
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "_compress",
|
||||||
|
value: function (t) {
|
||||||
|
if (t < 64) console.error("compress error: not enough data");
|
||||||
|
else {
|
||||||
|
for (var r = function (t) {
|
||||||
|
for (var r = new Array(132), e = 0; e < 16; e++) r[e] = t[4 * e] << 24,
|
||||||
|
r[e] |= t[4 * e + 1] << 16,
|
||||||
|
r[e] |= t[4 * e + 2] << 8,
|
||||||
|
r[e] |= t[4 * e + 3],
|
||||||
|
r[e] >>>= 0;
|
||||||
|
for (var n = 16; n < 68; n++) {
|
||||||
|
var o = r[n - 16] ^ r[n - 9] ^ dr(r[n - 3], 15);
|
||||||
|
o = o ^ dr(o, 15) ^ dr(o, 23),
|
||||||
|
r[n] = (o ^ dr(r[n - 13], 7) ^ r[n - 6]) >>> 0
|
||||||
|
}
|
||||||
|
for (n = 0; n < 64; n++) r[n + 68] = (r[n] ^ r[n + 4]) >>> 0;
|
||||||
|
return r
|
||||||
|
}(t), e = this.reg.slice(0), n = 0; n < 64; n++) {
|
||||||
|
var o = dr(e[0], 12) + e[4] + dr(yr(n), n),
|
||||||
|
i = ((o = dr(o = (4294967295 & o) >>> 0, 7)) ^ dr(e[0], 12)) >>> 0,
|
||||||
|
u = br(n, e[0], e[1], e[2]);
|
||||||
|
u = (4294967295 & (u = u + e[3] + i + r[n + 68])) >>> 0;
|
||||||
|
var s = mr(n, e[4], e[5], e[6]);
|
||||||
|
s = (4294967295 & (s = s + e[7] + o + r[n])) >>> 0,
|
||||||
|
e[3] = e[2],
|
||||||
|
e[2] = dr(e[1], 9),
|
||||||
|
e[1] = e[0],
|
||||||
|
e[0] = u,
|
||||||
|
e[7] = e[6],
|
||||||
|
e[6] = dr(e[5], 19),
|
||||||
|
e[5] = e[4],
|
||||||
|
e[4] = (s ^ dr(s, 9) ^ dr(s, 17)) >>> 0
|
||||||
|
}
|
||||||
|
for (var c = 0; c < 8; c++) this.reg[c] = (this.reg[c] ^ e[c]) >>> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
key: "_fill",
|
||||||
|
value: function () {
|
||||||
|
var t = 8 * this.size,
|
||||||
|
r = this.chunk.push(128) % 64;
|
||||||
|
for (64 - r < 8 && (r -= 64); r < 56; r++) this.chunk.push(0);
|
||||||
|
for (var e = 0; e < 4; e++) {
|
||||||
|
var n = Math.floor(t / 4294967296);
|
||||||
|
this.chunk.push(n >>> 8 * (3 - e) & 255)
|
||||||
|
}
|
||||||
|
for (e = 0; e < 4; e++) this.chunk.push(t >>> 8 * (3 - e) & 255)
|
||||||
|
}
|
||||||
|
}]),
|
||||||
|
t
|
||||||
|
}();
|
||||||
|
function dr(t, r) {
|
||||||
|
return (t << (r %= 32) | t >>> 32 - r) >>> 0
|
||||||
|
}
|
||||||
|
function yr(t) {
|
||||||
|
return 0 <= t && t < 16 ? 2043430169 : 16 <= t && t < 64 ? 2055708042 : void console.error("invalid j for constant Tj")
|
||||||
|
}
|
||||||
|
function br(t, r, e, n) {
|
||||||
|
return 0 <= t && t < 16 ? (r ^ e ^ n) >>> 0 : 16 <= t && t < 64 ? (r & e | r & n | e & n) >>> 0 : (console.error("invalid j for bool function FF"), 0)
|
||||||
|
}
|
||||||
|
function mr(t, r, e, n) {
|
||||||
|
return 0 <= t && t < 16 ? (r ^ e ^ n) >>> 0 : 16 <= t && t < 64 ? (r & e | ~r & n) >>> 0 : (console.error("invalid j for bool function GG"), 0)
|
||||||
|
}
|
||||||
|
enc_ = new gr;
|
||||||
|
return enc_.sum(n_str);
|
||||||
|
}
|
||||||
|
function generate_lm_g_EP(ua_n = ua) {
|
||||||
|
// function get_sz256f_2() {
|
||||||
|
// var r = [], k = 0, y = [0, 1, 0];
|
||||||
|
// for (var i = 255; i >= 0; i--) {
|
||||||
|
// r.push(i);
|
||||||
|
// }
|
||||||
|
// for (var i = 0; i < r.length; i++) {
|
||||||
|
// var a = r[i];
|
||||||
|
// k = (k * a + k + y[i % 3]) % 256;
|
||||||
|
// var b = r[k];
|
||||||
|
// r[i] = b, r[k] = a;
|
||||||
|
// }
|
||||||
|
// return r;
|
||||||
|
// }
|
||||||
|
var sz256f_2 = [233, 5, 1, 249, 162, 140, 57, 143, 19, 203, 254, 236, 99, 248, 93, 213, 79, 149, 216, 50, 145, 123, 240, 92, 23, 113, 130, 53, 235, 220, 201, 136, 223, 155, 190, 242, 243, 42, 52, 214, 151, 232, 97, 187, 163, 222, 30, 78, 47, 71, 49, 170, 247, 196, 25, 156, 183, 182, 217, 180, 147, 124, 208, 69, 215, 200, 161, 154, 91, 60, 133, 224, 119, 164, 221, 45, 98, 40, 186, 120, 51, 167, 38, 90, 194, 212, 129, 56, 87, 195, 144, 44, 75, 84, 81, 13, 197, 245, 36, 250, 115, 100, 105, 252, 206, 103, 112, 202, 114, 138, 192, 21, 116, 173, 181, 29, 82, 125, 141, 16, 211, 131, 225, 118, 31, 101, 77, 146, 135, 150, 62, 66, 67, 176, 0, 41, 46, 59, 107, 178, 43, 26, 189, 128, 8, 207, 166, 110, 3, 229, 85, 54, 63, 11, 32, 4, 234, 142, 72, 58, 33, 231, 12, 230, 102, 86, 70, 159, 226, 65, 237, 34, 244, 76, 132, 122, 111, 95, 179, 152, 175, 18, 177, 6, 126, 193, 219, 74, 134, 2, 61, 251, 191, 168, 209, 241, 137, 165, 88, 238, 160, 174, 153, 157, 199, 48, 22, 64, 246, 7, 139, 55, 27, 188, 148, 204, 127, 171, 89, 37, 172, 205, 121, 20, 28, 17, 169, 15, 227, 117, 80, 218, 198, 10, 106, 9, 39, 210, 104, 83, 109, 24, 108, 228, 184, 96, 185, 158, 14, 255, 239, 68, 94, 35, 73, 253];
|
||||||
|
var k = 0, s = '';
|
||||||
|
for (var i = 0; i < ua_n.length; i++) {
|
||||||
|
var i1 = (i + 1) % 256;
|
||||||
|
var a = sz256f_2[i1];
|
||||||
|
k = (k + a) % 256;
|
||||||
|
var c = sz256f_2[k];
|
||||||
|
sz256f_2[i1] = c;
|
||||||
|
sz256f_2[k] = a;
|
||||||
|
s += String.fromCharCode(ua_n.charCodeAt(i) ^ sz256f_2[(a + c) % 256]);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_str_chr_list(one_str) {
|
||||||
|
var r = []; // 当然也可以用map实现
|
||||||
|
for (var i = 0; i < one_str.length; i++) {
|
||||||
|
r.push(one_str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function generate_szenc_head8p1() {
|
||||||
|
var z = Math.random() * 65535;
|
||||||
|
var a = z & 255;
|
||||||
|
var b = (z >> 8) & 255, d = [];
|
||||||
|
d.push((a & 170) | 1);
|
||||||
|
d.push((a & 85) | 0);
|
||||||
|
d.push((b & 170) | 0);
|
||||||
|
d.push((b & 85) | 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function generate_szenc_head8p2() {
|
||||||
|
var a = ((Math.random() * 240) >> 0) + 1;
|
||||||
|
var b = ((Math.random() * 255) >> 0) & 77, c = [1, 4, 5, 7], d = [];
|
||||||
|
for (var i = 0; i < c.length; i++) {
|
||||||
|
b = b | (1 << c[i]);
|
||||||
|
}
|
||||||
|
d.push((a & 170) | 1);
|
||||||
|
d.push((a & 85) | 0);
|
||||||
|
d.push((b & 170) | 0);
|
||||||
|
d.push((b & 85) | 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function get_szenc_tail(sz96_n) {
|
||||||
|
key_sz_6 = [145, 110, 66, 189, 44, 211]
|
||||||
|
var a = [];
|
||||||
|
for (var i = 0; i < 94; i += 3) {
|
||||||
|
var b = sz96_n[i];
|
||||||
|
var c = sz96_n[i + 1];
|
||||||
|
var d = sz96_n[i + 2];
|
||||||
|
var e = (Math.random() * 1000) & 255;
|
||||||
|
a.push((e & key_sz_6[0]) | (b & key_sz_6[1]));
|
||||||
|
a.push((e & key_sz_6[2]) | (c & key_sz_6[3]));
|
||||||
|
a.push((e & key_sz_6[4]) | (d & key_sz_6[5]));
|
||||||
|
a.push(((b & key_sz_6[0]) | (c & key_sz_6[2])) | (d & key_sz_6[4]));
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
function generate_lm_g_ab_head4() {
|
||||||
|
var s = '';
|
||||||
|
var a = (Math.random() * 65535) & 255, b = (Math.random() * 40) >> 0;
|
||||||
|
s += String.fromCharCode((a & 170) | 1);
|
||||||
|
s += String.fromCharCode((a & 85) | 2);
|
||||||
|
s += String.fromCharCode((b & 170) | 80);
|
||||||
|
s += String.fromCharCode((b & 85) | 2);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_list_str(one_list) {
|
||||||
|
var s = '';
|
||||||
|
for (var i = 0; i < one_list.length; i++) {
|
||||||
|
s += String.fromCharCode(one_list[i]);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_lm_g_ab(lm_g_lm_n) {
|
||||||
|
// function get_sz256() {
|
||||||
|
// var raw = [];
|
||||||
|
// var z = 0;
|
||||||
|
// for (var i = 255; i >= 0; i--) {
|
||||||
|
// raw.push(i);
|
||||||
|
// }
|
||||||
|
// for (var i = 0; i < raw.length; i++) {
|
||||||
|
// z += 211;
|
||||||
|
// var a = z % 256;
|
||||||
|
// var b = raw[i];
|
||||||
|
// var c = raw[a];
|
||||||
|
// raw[a] = b;
|
||||||
|
// raw[i] = c;
|
||||||
|
// z = (raw[i + 1] * a) + a;
|
||||||
|
// }
|
||||||
|
// return raw;
|
||||||
|
// }
|
||||||
|
var fixed_sz256_n = [194, 249, 255, 165, 114, 67, 251, 187, 174, 231, 164, 237, 124, 235, 68, 83, 206, 79, 142, 167, 30, 77, 0, 93, 118, 29, 32, 161, 2, 171, 243, 179, 42, 170, 223, 119, 98, 222, 219, 57, 245, 135, 197, 13, 186, 202, 88, 184, 214, 12, 76, 185, 116, 74, 54, 53, 104, 208, 158, 163, 82, 173, 253, 240, 172, 63, 191, 207, 25, 15, 201, 203, 215, 236, 183, 233, 145, 127, 72, 6, 16, 10, 228, 35, 232, 159, 66, 168, 108, 71, 217, 75, 33, 155, 112, 128, 36, 24, 138, 50, 211, 23, 107, 14, 247, 137, 175, 242, 234, 157, 199, 49, 139, 85, 81, 17, 180, 86, 120, 78, 51, 205, 169, 148, 181, 3, 94, 106, 252, 220, 150, 47, 151, 84, 212, 18, 149, 182, 100, 123, 121, 156, 154, 152, 126, 204, 60, 133, 132, 248, 7, 91, 58, 59, 20, 97, 113, 117, 131, 46, 250, 224, 21, 73, 146, 31, 193, 69, 140, 125, 9, 39, 89, 5, 65, 141, 218, 80, 1, 70, 64, 166, 87, 189, 55, 147, 22, 26, 143, 61, 144, 99, 92, 44, 129, 130, 227, 103, 90, 192, 198, 244, 136, 101, 246, 153, 56, 38, 4, 178, 221, 162, 134, 37, 111, 28, 216, 96, 102, 210, 254, 196, 195, 230, 241, 62, 11, 122, 52, 40, 41, 229, 226, 225, 48, 45, 160, 105, 8, 115, 34, 43, 209, 95, 239, 190, 188, 109, 27, 19, 176, 213, 200, 238, 177, 110];
|
||||||
|
// var fixed_sz256_n = get_sz256();
|
||||||
|
var z = 0;
|
||||||
|
var st = "";
|
||||||
|
for (var i = 0; i < lm_g_lm_n.length; i++) {
|
||||||
|
var a = (i + 1) % 256;
|
||||||
|
var c = fixed_sz256_n[a];
|
||||||
|
z = (z + c) % 256;
|
||||||
|
var e = fixed_sz256_n[z];
|
||||||
|
fixed_sz256_n[a] = e;
|
||||||
|
fixed_sz256_n[z] = c;
|
||||||
|
var g = (e + c) % 256;
|
||||||
|
var h = lm_g_lm_n.charCodeAt(i);
|
||||||
|
var j = fixed_sz256_n[g];
|
||||||
|
var k = h ^ j;
|
||||||
|
var l = String.fromCharCode(k);
|
||||||
|
st += l;
|
||||||
|
}
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
function get_raw_ab(lm_get_ab_n, key_str = info_dic.s4) {
|
||||||
|
var s = "", bw = 0;
|
||||||
|
for (var i = 0; i < lm_get_ab_n.length; i += 3) {
|
||||||
|
var cl = 16;
|
||||||
|
var tcz = 0;
|
||||||
|
var sof = 16515072;
|
||||||
|
for (var j = i; j < i + 3; j++) {
|
||||||
|
if (j < lm_get_ab_n.length) {
|
||||||
|
var tlcy = lm_get_ab_n.charCodeAt(j) & 255;
|
||||||
|
tcz = tcz | (tlcy << cl);
|
||||||
|
cl -= 8;
|
||||||
|
} else {
|
||||||
|
bw += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var h = 18; h >= (6 * bw); h -= 6) {
|
||||||
|
var tsz = tcz & sof;
|
||||||
|
s += key_str[tsz >> h];
|
||||||
|
sof = sof / 64;
|
||||||
|
}
|
||||||
|
s += '='.repeat(bw);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function get_random_number(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
var info_dic = { "s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
|
||||||
|
"s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
|
||||||
|
"s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
|
||||||
|
"s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
|
||||||
|
"s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe" }
|
||||||
|
var t1 = Date.now();
|
||||||
|
var s = [];
|
||||||
|
|
||||||
|
var t2 = Date.now() - 1 + get_random_number(1, 3);
|
||||||
|
|
||||||
|
var EP = get_raw_ab(generate_lm_g_EP(ua), key_str = info_dic.s3), eEP = enc_sum(EP);
|
||||||
|
|
||||||
|
s.push("env_fx_list", "dpf_ua_dic", 1, 0, 8, "dpf", "", "ua", 6241, 6383, "1.0.1.19-fix.01", "ink", 3, "0X21_dic"); // 固定即可
|
||||||
|
|
||||||
|
var t3 = Date.now() + get_random_number(4, 15);
|
||||||
|
var eedp = enc_sum(enc_sum(dpf + 'dhzx'));
|
||||||
|
|
||||||
|
s.push(t3, "reg_dic", 1, 0, eedp, "eedh", EP, eEP, t2, [3, 82], 41, [1, 0, 1, 0, 1]);
|
||||||
|
|
||||||
|
var t4 = Date.now() + get_random_number(100, 1000);
|
||||||
|
|
||||||
|
s1 = ((t4 - 1721836800000) / 1000 / 60 / 60 / 24 / 14) >> 0, szenc_o95_tail41 = [49, 52, 52, 49, 124, 56, 51, 56, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 49, 51, 124, 49, 52, 52, 49, 124, 57, 54, 49, 124, 87, 105, 110, 51, 50];
|
||||||
|
|
||||||
|
s.push(s1, 6, (t3 - t1 + 3) & 255, t3 & 255, (t3 >> 8) & 255, (t3 >> 16) & 255, (t3 >> 24) & 255, (t3 / 256 / 256 / 256 / 256) & 255);
|
||||||
|
|
||||||
|
var s2 = (t3 / 256 / 256 / 256 / 256 / 256) & 255;
|
||||||
|
|
||||||
|
s.push(s2, (s2 % 256) & 255, (s2 / 256) & 255, [211, 2, 5, 1, 129], 129, 0, 211, 2, 5, 1, 0, 0, 0, 0, eedp[9], eedp[18], 3, eedp[3], 82, 177, 4, 44, eEP[11], eEP[21], 5, eEP[5], t2 & 255, (t2 >> 8) & 255, (t2 >> 16) & 255, (t2 >> 24) & 255, (t2 / 256 / 256 / 256 / 256) & 255, (t2 / 256 / 256 / 256 / 256 / 256) & 255, 3, 97, 24, 0, 0, 239, 24, 0, 0, "screec_dic", "screen_str", szenc_o95_tail41, 41, 41, 0);
|
||||||
|
|
||||||
|
var s3 = ((t3 + 3) & 255) + ',', s4 = get_str_chr_list(s3);
|
||||||
|
|
||||||
|
s.push(s3, s4, s4.length, s4.length & 255, (s4.length >> 8) & 255);
|
||||||
|
|
||||||
|
szenc_head8_p1 = generate_szenc_head8p1(), szenc_head8_p2 = generate_szenc_head8p2(), szenc_head8 = szenc_head8_p1.concat(szenc_head8_p2), s5 = [], s6 = [24, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 55, 56, 57, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 79, 80, 84, 85];
|
||||||
|
for (var i = 0; i < s6.length; i++) {
|
||||||
|
s5.push(s[s6[i]]);
|
||||||
|
}
|
||||||
|
s.push(szenc_head8);
|
||||||
|
var s7 = szenc_head8.concat(s5);
|
||||||
|
|
||||||
|
var s8 = s7[0];
|
||||||
|
for (var i = 1; i < s7.length; i++) {
|
||||||
|
s8 = s8 ^ s7[i];
|
||||||
|
}
|
||||||
|
s.push(s8);
|
||||||
|
|
||||||
|
enc_s_i = [34, 44, 56, 61, 73, 29, 70, 45, 35, 49, 38, 66, 51, 68, 28, 48, 64, 47, 30, 71, 26, 55, 31, 69, 59, 40, 62, 63, 27, 72, 41, 74, 57, 52, 42, 39, 33, 67, 53, 43, 65, 46, 36, 24, 60, 32, 79, 80, 84, 85];
|
||||||
|
szenc_o95_head50 = [];
|
||||||
|
for (var i = 0; i < enc_s_i.length; i++) {
|
||||||
|
szenc_o95_head50.push(s[enc_s_i[i]]);
|
||||||
|
}
|
||||||
|
szenc_o95 = [];
|
||||||
|
szenc_o95 = szenc_o95.concat(szenc_o95_head50, szenc_o95_tail41, s4, [s8]);
|
||||||
|
|
||||||
|
szenc_tail = get_szenc_tail(szenc_o95);
|
||||||
|
szenc = szenc_head8.concat(szenc_tail);
|
||||||
|
|
||||||
|
lm_get_ab_head4 = generate_lm_g_ab_head4();
|
||||||
|
|
||||||
|
var lm_get_lm = get_list_str(szenc);
|
||||||
|
var lm_get_ab_tail = get_lm_g_ab(lm_get_lm);
|
||||||
|
var lm_get_ab = lm_get_ab_head4 + lm_get_ab_tail
|
||||||
|
return get_raw_ab(lm_get_ab);
|
||||||
|
}
|
||||||
96
internal/sign/ac_signature.go
Normal file
96
internal/sign/ac_signature.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func GetACSignature(oneSite, oneNonce, uaN string, oneTimeStamp int64) string {
|
||||||
|
if oneTimeStamp == 0 {
|
||||||
|
oneTimeStamp = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
calOneStr := func(s string, iv int) int {
|
||||||
|
k := iv
|
||||||
|
for _, ch := range s {
|
||||||
|
k = int(((k ^ int(ch)) * 65599) & 0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
calOneStr3 := func(s string, iv int) int {
|
||||||
|
k := iv
|
||||||
|
for _, ch := range s {
|
||||||
|
k = int((int64(k)*65599 + int64(ch)) & 0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
getOneChr := func(code int) string {
|
||||||
|
code &= 0x3F
|
||||||
|
if code < 26 {
|
||||||
|
return string(rune('A' + code))
|
||||||
|
} else if code < 52 {
|
||||||
|
return string(rune('a' + code - 26))
|
||||||
|
} else if code < 62 {
|
||||||
|
return string(rune('0' + code - 52))
|
||||||
|
} else if code == 62 {
|
||||||
|
return "+"
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
encNumToStr := func(val uint32) string {
|
||||||
|
var s string
|
||||||
|
for i := 24; i >= 0; i -= 6 {
|
||||||
|
s += getOneChr(int((val >> uint(i)) & 0x3F))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
signHead := "_02B4Z6wo00f01"
|
||||||
|
timeStampS := itoa(oneTimeStamp)
|
||||||
|
|
||||||
|
a := calOneStr(oneSite, calOneStr(timeStampS, 0)) % 65521
|
||||||
|
|
||||||
|
xorVal := uint32(oneTimeStamp) ^ uint32(a*65521)
|
||||||
|
b := uint64(0x80F0) << 32 | uint64(xorVal)
|
||||||
|
bStr := itoa(int64(b))
|
||||||
|
|
||||||
|
c := calOneStr(bStr, 0)
|
||||||
|
|
||||||
|
d := encNumToStr(uint32(b >> 2))
|
||||||
|
e := uint32((b >> 32) & 0xFFFFFFFF)
|
||||||
|
f := encNumToStr(uint32((b << 28) | (uint64(e) >> 4)))
|
||||||
|
g := uint32(0x22AA7A58) ^ uint32(b&0xFFFFFFFF)
|
||||||
|
h := encNumToStr(uint32((e << 26) | (g >> 6)))
|
||||||
|
i := getOneChr(int(g & 0x3F))
|
||||||
|
|
||||||
|
j := (uint32(calOneStr(uaN, c)%65521) << 16) | uint32(calOneStr(oneNonce, c)%65521)
|
||||||
|
k := encNumToStr(j >> 2)
|
||||||
|
l := encNumToStr(uint32((j << 28) | ((0x8000 ^ uint32(b&0xFFFFFFFF)) >> 4)))
|
||||||
|
m := encNumToStr(uint32(a))
|
||||||
|
|
||||||
|
n := signHead + d + f + h + i + k + l + m
|
||||||
|
|
||||||
|
oHex := itoa(int64(calOneStr3(n, 0)))
|
||||||
|
if len(oHex) < 2 {
|
||||||
|
oHex = "00"
|
||||||
|
}
|
||||||
|
o := oHex[len(oHex)-2:]
|
||||||
|
|
||||||
|
return n + o
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var digits []byte
|
||||||
|
n := v
|
||||||
|
if n < 0 {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
for n > 0 {
|
||||||
|
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(digits)
|
||||||
|
}
|
||||||
54
internal/sign/bogus.go
Normal file
54
internal/sign/bogus.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package sign
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed sign.js
|
||||||
|
var signJS []byte
|
||||||
|
|
||||||
|
//go:embed a_bogus.js
|
||||||
|
var abogusJS []byte
|
||||||
|
|
||||||
|
func GenerateSignature(wssURL string) (string, error) {
|
||||||
|
vm := goja.New()
|
||||||
|
vm.Set("console", goja.Null())
|
||||||
|
if _, err := vm.RunString(string(signJS)); err != nil {
|
||||||
|
return "", fmt.Errorf("run sign.js: %w", err)
|
||||||
|
}
|
||||||
|
fn := vm.Get("get_sign")
|
||||||
|
if fn == nil || goja.IsUndefined(fn) {
|
||||||
|
return "", fmt.Errorf("get_sign not found in sign.js")
|
||||||
|
}
|
||||||
|
result, err := fn.Export().(goja.Callable)(goja.Undefined(), vm.ToValue(wssURL))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call get_sign: %w", err)
|
||||||
|
}
|
||||||
|
return result.Export().(string), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetABogus(urlParams map[string]string, userAgent string) (string, error) {
|
||||||
|
vm := goja.New()
|
||||||
|
if _, err := vm.RunString(string(abogusJS)); err != nil {
|
||||||
|
return "", fmt.Errorf("run a_bogus.js: %w", err)
|
||||||
|
}
|
||||||
|
fn := vm.Get("get_ab")
|
||||||
|
if fn == nil || goja.IsUndefined(fn) {
|
||||||
|
return "", fmt.Errorf("get_ab not found in a_bogus.js")
|
||||||
|
}
|
||||||
|
dpf := ""
|
||||||
|
for k, v := range urlParams {
|
||||||
|
if dpf != "" {
|
||||||
|
dpf += "&"
|
||||||
|
}
|
||||||
|
dpf += k + "=" + v
|
||||||
|
}
|
||||||
|
result, err := fn.Export().(goja.Callable)(goja.Undefined(), vm.ToValue(dpf), vm.ToValue(userAgent))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call get_ab: %w", err)
|
||||||
|
}
|
||||||
|
return result.Export().(string), nil
|
||||||
|
}
|
||||||
7011
internal/sign/sign.js
Normal file
7011
internal/sign/sign.js
Normal file
File diff suppressed because one or more lines are too long
77
main.go
Normal file
77
main.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/api"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/broadcaster"
|
||||||
|
"github.com/qingwuyingxing/douyin-live-fetcher/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
//go:embed assets/icon.png
|
||||||
|
//go:embed build/windows/icon.ico
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
forceQuit atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := db.InitDB(); err != nil {
|
||||||
|
log.Printf("[main] db init error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bc := broadcaster.NewBroadcaster()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := api.StartServer(bc, http.FS(assets), 5000); err != nil {
|
||||||
|
log.Printf("[main] API server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if resp, err := http.Get("http://127.0.0.1:5000/api/status"); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
log.Println("[main] API server ready")
|
||||||
|
|
||||||
|
cfg := &options.App{
|
||||||
|
Title: "青梧映星",
|
||||||
|
Width: 1280,
|
||||||
|
Height: 800,
|
||||||
|
MinWidth: 1024,
|
||||||
|
MinHeight: 600,
|
||||||
|
WindowStartState: options.Normal,
|
||||||
|
AssetServer: &assetserver.Options{Assets: assets},
|
||||||
|
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||||
|
Windows: &windows.Options{Theme: windows.SystemDefault},
|
||||||
|
OnBeforeClose: func(ctx context.Context) bool {
|
||||||
|
if forceQuit.Load() { return false }
|
||||||
|
runtime.WindowHide(ctx)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
OnShutdown: func(context.Context) {
|
||||||
|
forceQuit.Store(true)
|
||||||
|
},
|
||||||
|
Bind: []interface{}{&App{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wails.Run(cfg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
wails.json
Normal file
11
wails.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "douyin-live-fetcher",
|
||||||
|
"outputfilename": "go_win_template",
|
||||||
|
"frontend:dir": "frontend",
|
||||||
|
"frontend:install": "npm install",
|
||||||
|
"frontend:build": "npm run build",
|
||||||
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
"frontend:dev:server": "http://localhost:5173",
|
||||||
|
"author": { "name": "Geliebte", "email": "" },
|
||||||
|
"info": { "productname": "青梧映星", "version": "1.1.0", "companyname": "青梧映星软件开发工作室", "copyright": "© 2026 青梧映星软件开发工作室" }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user