tourfp 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/package.json +1 -1
- package/tourfp.js +336 -88
package/README.md
CHANGED
|
@@ -29,13 +29,13 @@ Với Angular, nên khai báo trong `angular.json` để thư viện được n
|
|
|
29
29
|
Hoặc không cần cài, nạp bằng một thẻ script qua CDN:
|
|
30
30
|
|
|
31
31
|
```html
|
|
32
|
-
<script src="https://cdn.jsdelivr.net/npm/tourfp@0.3.
|
|
32
|
+
<script src="https://cdn.jsdelivr.net/npm/tourfp@0.3.2/tourfp.js"></script>
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
Để dùng thử nhanh trên một trang đang mở (trang phải cho phép script từ `cdn.jsdelivr.net`), dán dòng này vào console:
|
|
36
36
|
|
|
37
37
|
```js
|
|
38
|
-
document.head.appendChild(Object.assign(document.createElement('script'), { src: 'https://cdn.jsdelivr.net/npm/tourfp@0.3.
|
|
38
|
+
document.head.appendChild(Object.assign(document.createElement('script'), { src: 'https://cdn.jsdelivr.net/npm/tourfp@0.3.2/tourfp.js' }));
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Tuỳ chọn, đặt trước khi nạp thư viện:
|
|
@@ -89,6 +89,7 @@ Mở <http://localhost:8000>:
|
|
|
89
89
|
- **Chạy thử** — chép một đoạn mã gồm cả thư viện lẫn tour; mở màn bắt đầu của tour, dán vào console là tour phát ngay.
|
|
90
90
|
Khi chạy thử, thanh ghi tour không hiện (chế độ chỉ phát); gọi `TourFP.showPanel()` nếu cần hiện lại.
|
|
91
91
|
- **Xoá**, **Tải JSON**, và xem danh sách bước của từng tour.
|
|
92
|
+
- **Sửa tên**: bấm vào tên một bước để đặt tên hiển thị cho bước đó (để trống thì dùng lại tên tự lấy từ giao diện); nút **Sửa tên tour** đổi tên và mô tả của tour. Tên đã đặt được dùng trong lời hướng dẫn khi phát và trong việc tìm tour của trợ lý.
|
|
92
93
|
|
|
93
94
|
Cấu hình bằng biến môi trường: `TOURFP_TARGET` là trang web tour chạy trên đó (mặc định `https://tcinvest.tcbs.com.vn`),
|
|
94
95
|
`TOURFP_DATA` là thư mục chứa file SQLite.
|
|
@@ -127,6 +128,38 @@ TourFP.start({ mode: 'direct', to: 3 }); // chạy tour đang nạp: vào thẳ
|
|
|
127
128
|
Chế độ vào thẳng tự bấm thay người dùng. Với tour đi qua thao tác có hậu quả (đặt lệnh, xác nhận giao dịch), nên đặt `to` dừng
|
|
128
129
|
trước các bước đó, hoặc chỉ đưa ra lựa chọn hướng dẫn từng bước.
|
|
129
130
|
|
|
131
|
+
### Kiểm thử tự động
|
|
132
|
+
|
|
133
|
+
Mục **Kiểm thử tự động** trên trang quản trị chạy lại mọi tour trong một trình duyệt thật và cho biết tour nào còn đúng,
|
|
134
|
+
tour nào gãy ở bước nào. Bấm **Chạy kiểm thử** để chạy ngay, hoặc bật **Chạy hằng ngày lúc** để máy chủ tự chạy mỗi ngày
|
|
135
|
+
một lần (máy chủ phải đang bật vào giờ đó; bật muộn hơn thì chạy bù ngay).
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install playwright
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Bộ chạy (`server/runner.py`) dùng Chrome có sẵn trên máy qua Playwright, nạp `tourfp.js` trước mã của trang, rồi với từng
|
|
142
|
+
bước: hỏi thư viện toạ độ của đích và rê hoặc bấm vào đó bằng chuột thật. Kết quả của mỗi tour:
|
|
143
|
+
|
|
144
|
+
| Kết quả | Nghĩa |
|
|
145
|
+
|---|---|
|
|
146
|
+
| Đạt | Mọi bước được chạy đều tìm thấy đích |
|
|
147
|
+
| Lưu ý | Đạt, nhưng có bước nhiều ứng viên ngang điểm, hoặc sau cú bấm màn hình không giống lúc ghi |
|
|
148
|
+
| Lỗi | Một bước không tìm thấy đích; kèm số bước và lý do |
|
|
149
|
+
| Cần đăng nhập | Trình duyệt kiểm thử chưa đăng nhập hoặc phiên đã hết |
|
|
150
|
+
| Bị chặn | Trang đích hiện màn xác minh chống bot nên trình duyệt tự động không vào được |
|
|
151
|
+
|
|
152
|
+
**Bấm thật.** Bộ chạy bấm thật trên trang và đi hết mọi bước của tour, kể cả bước đặt lệnh hay xác nhận nếu tour có. Tour
|
|
153
|
+
được kiểm duyệt lúc tạo; tour nào kết thúc bằng thao tác có hậu quả thì nên chạy trên môi trường thử hoặc tài khoản thử.
|
|
154
|
+
|
|
155
|
+
**Đăng nhập.** Trình duyệt kiểm thử dùng một hồ sơ riêng trong `server/data/browser-profile`. Bấm **Mở trình duyệt để đăng
|
|
156
|
+
nhập**, tự đăng nhập trong cửa sổ hiện ra rồi đóng nó; bộ chạy không bao giờ nhập tài khoản hay mật khẩu.
|
|
157
|
+
|
|
158
|
+
**Chống bot.** Nếu trang đích chặn trình duyệt tự động (ví dụ màn "Just a moment…"), lần chạy được ghi là "Bị chặn" và bộ
|
|
159
|
+
chạy không tìm cách vượt qua. Khi đó cần trỏ `TOURFP_TARGET` tới một địa chỉ không chặn trình duyệt tự động, như môi trường
|
|
160
|
+
thử của app, hoặc nhờ bên vận hành đưa máy kiểm thử vào danh sách cho phép. **Cửa sổ Chrome.** Mặc định bộ chạy mở Chrome có cửa sổ để bạn xem nó thao tác từng bước. Bỏ ô **Hiện cửa sổ Chrome khi chạy**
|
|
161
|
+
trên trang quản trị để chạy ẩn (hợp với lịch chạy hằng ngày). `TOURFP_TEST_HEADLESS=1` đổi mặc định ban đầu thành chạy ẩn.
|
|
162
|
+
|
|
130
163
|
### Nối với mô hình AI
|
|
131
164
|
|
|
132
165
|
Mặc định trợ lý tìm bằng so khớp từ khoá, không gọi AI. Bật AI bằng biến môi trường `TOURFP_AI`:
|
package/package.json
CHANGED
package/tourfp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* TourFP 0.3.
|
|
1
|
+
/* TourFP 0.3.2 — bản thử: ghi tour theo thao tác và khớp phần tử bằng "dấu vân tay".
|
|
2
2
|
* - Không dùng class/id. Không phụ thuộc thư viện.
|
|
3
3
|
* - Chữ lấy từ API (tên, tên kế hoạch…) và chữ số bị loại khỏi dấu vân tay.
|
|
4
4
|
* Để lọc được, file này phải được nạp TRƯỚC khi app gọi API (đặt trong <head>).
|
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
const hash = s => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36); };
|
|
29
29
|
const isUi = e => !!(e && e.closest && e.closest('[data-tourfp-ui]'));
|
|
30
30
|
const visible = e => e.getClientRects().length > 0;
|
|
31
|
+
// Nằm hẳn ngoài màn hình theo chiều ngang mà không có khung cuộn ngang nào đưa nó vào được: đó là ngăn kéo/bảng trượt đang đóng
|
|
32
|
+
// (hoặc bản sao ẩn của nó). Người dùng không thấy và không bấm được, nên coi như chưa có trên màn.
|
|
33
|
+
function offscreen(e) {
|
|
34
|
+
const r = e.getBoundingClientRect();
|
|
35
|
+
if (r.right > 0 && r.left < innerWidth) return false;
|
|
36
|
+
for (let p = e.parentElement; p && p !== document.documentElement; p = p.parentElement) {
|
|
37
|
+
if (p.scrollWidth > p.clientWidth + 4) { const o = getComputedStyle(p).overflowX; if (o === 'auto' || o === 'scroll') return false; }
|
|
38
|
+
}
|
|
39
|
+
return document.documentElement.scrollWidth <= innerWidth + 4;
|
|
40
|
+
}
|
|
31
41
|
const tagOf = e => e.tagName.toLowerCase();
|
|
32
42
|
|
|
33
43
|
// ---------- giá trị từ API: nghe mọi phản hồi JSON, nhớ các chuỗi trong đó ----------
|
|
@@ -36,7 +46,7 @@
|
|
|
36
46
|
const keyOf = s => norm(s).toLowerCase();
|
|
37
47
|
function feed(v, depth) {
|
|
38
48
|
if (v == null || depth > 12 || api.set.size >= API_MAX) return;
|
|
39
|
-
if (typeof v === 'string') { const s = keyOf(v); if (s.length >= 2 && s.length <= 120 && hasLetters(s)) api.set.add(s); }
|
|
49
|
+
if (typeof v === 'string') { const s = keyOf(v); if (s.length >= 2 && s.length <= 120 && hasLetters(s)) { api.set.add(s); if (/\d/.test(s)) api.set.add(stripNum(s)); } }
|
|
40
50
|
else if (Array.isArray(v)) for (const x of v) feed(x, depth + 1);
|
|
41
51
|
else if (typeof v === 'object') for (const k in v) feed(v[k], depth + 1);
|
|
42
52
|
}
|
|
@@ -62,6 +72,46 @@
|
|
|
62
72
|
return p;
|
|
63
73
|
};
|
|
64
74
|
}
|
|
75
|
+
// ---------- dữ liệu cá nhân: không bao giờ được nằm trong tour ----------
|
|
76
|
+
// Thư viện có thể được nạp sau khi app đã gọi API hồ sơ, nên không thể chỉ dựa vào việc "nghe" phản hồi. Thêm hai nguồn:
|
|
77
|
+
// - những gì app lưu trong localStorage/sessionStorage (hồ sơ, token đăng nhập có tên bên trong): chỉ dùng để nhận ra dữ liệu
|
|
78
|
+
// cá nhân, không dùng để phân loại nhãn (ở đó có cả chữ cố định của app);
|
|
79
|
+
// - hình dạng của chuỗi: họ tên người Việt, email.
|
|
80
|
+
const priv = new Set();
|
|
81
|
+
function feedPriv(v, depth) {
|
|
82
|
+
if (v == null || depth > 8 || priv.size >= 5000) return;
|
|
83
|
+
if (typeof v === 'string') {
|
|
84
|
+
if (v.length > 120) { // token dạng a.b.c: phần giữa là JSON chứa tên, email
|
|
85
|
+
const p = v.split('.');
|
|
86
|
+
if (p.length === 3 && p[1].length < 4000) try { feedPriv(JSON.parse(decodeURIComponent(escape(atob(p[1].replace(/-/g, '+').replace(/_/g, '/'))))), depth + 1); } catch (_) { /* không phải token */ }
|
|
87
|
+
else if (v.length < 20000 && /^[[{]/.test(v)) try { feedPriv(JSON.parse(v), depth + 1); } catch (_) { /* không phải JSON */ }
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (/^[[{]/.test(v)) { try { return feedPriv(JSON.parse(v), depth + 1); } catch (_) { /* chuỗi thường */ } }
|
|
91
|
+
const k = keyOf(v);
|
|
92
|
+
if (k.length >= 4 && hasLetters(k) && k.includes(' ')) priv.add(k); // chỉ giữ chuỗi nhiều từ (họ tên, địa chỉ); từ đơn dễ trùng nhãn
|
|
93
|
+
} else if (Array.isArray(v)) for (const x of v.slice(0, 50)) feedPriv(x, depth + 1);
|
|
94
|
+
else if (typeof v === 'object') for (const k in v) feedPriv(v[k], depth + 1);
|
|
95
|
+
}
|
|
96
|
+
function readStores() {
|
|
97
|
+
for (const st of [window.localStorage, window.sessionStorage]) {
|
|
98
|
+
try { for (let i = 0; i < st.length && i < 200; i++) { const k = st.key(i); if (!/^tourfp|i18n|lang|locale|translat/i.test(k)) feedPriv(st.getItem(k), 0); } } catch (_) { /* không đọc được thì thôi */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
readStores();
|
|
102
|
+
// chỉ các họ hiếm khi là từ thường trong giao diện (bỏ Hồ, Lý, Cao, Hà, Mai…: "Hồ Sơ", "Lý Do" là nhãn); phần còn lại nhờ nguồn thứ nhất
|
|
103
|
+
const SURNAME = /^(nguy[eễ]n|tr[aầ]n|l[eê]|ph[aạ]m|ho[aà]ng|hu[yỳ]nh|phan|v[uũ]|v[oõ]|[dđ][aặ]ng|b[uù]i|[dđ][oỗ]|ng[oô]|tr[iị]nh|qu[aá]ch|tr[uư][oơ]ng)$/i;
|
|
104
|
+
const CAP = /^[A-ZÀ-ỸĐ][A-Za-zÀ-ỹĐđ]*$/;
|
|
105
|
+
function looksPersonal(v) {
|
|
106
|
+
v = norm(v);
|
|
107
|
+
if (/[^\s@]+@[^\s@]+\.[a-z]{2,}/i.test(v)) return true;
|
|
108
|
+
const w = v.replace(/^(xin chào|chào|hi|hello)[,!]?\s+/i, '').split(' ');
|
|
109
|
+
// họ tên: 2–5 từ đều viết hoa chữ đầu (hoặc viết hoa hết), từ đầu là một họ phổ biến
|
|
110
|
+
return w.length >= 2 && w.length <= 5 && SURNAME.test(w[0]) && w.every(x => CAP.test(x));
|
|
111
|
+
}
|
|
112
|
+
const isPersonal = v => looksPersonal(v) || priv.has(keyOf(v));
|
|
113
|
+
// "là dữ liệu": giá trị API hoặc dữ liệu cá nhân
|
|
114
|
+
const isData = v => isApiValue(v) || isPersonal(v);
|
|
65
115
|
function isApiValue(piece) {
|
|
66
116
|
const k = keyOf(piece);
|
|
67
117
|
if (api.set.has(k)) return true;
|
|
@@ -70,7 +120,8 @@
|
|
|
70
120
|
}
|
|
71
121
|
|
|
72
122
|
// ---------- tín hiệu ----------
|
|
73
|
-
//
|
|
123
|
+
// Tách chữ của phần tử thành các mẩu, mỗi dòng chữ một mẩu. Chữ số được thay bằng # chứ không cắt bỏ:
|
|
124
|
+
// "1M" → "#M", "% EPS 1 năm" → "% EPS # năm", "0.0 tr" → "# tr". Dòng chỉ có số ("82.9%") thì bỏ.
|
|
74
125
|
function pieces(e) {
|
|
75
126
|
const out = [], num = [];
|
|
76
127
|
let digits = false, nodes = 0;
|
|
@@ -78,10 +129,12 @@
|
|
|
78
129
|
// chỉ cần vài mẩu đầu: với khối lớn (thẻ, bảng) đó là tiêu đề
|
|
79
130
|
for (let n; (n = w.nextNode()) && nodes < 40 && out.length < 6;) {
|
|
80
131
|
const v = n.nodeValue;
|
|
81
|
-
if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style'))) continue;
|
|
132
|
+
if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style,mat-icon,.material-icons'))) continue;
|
|
82
133
|
nodes++;
|
|
83
|
-
|
|
84
|
-
|
|
134
|
+
const d = /\d/.test(v);
|
|
135
|
+
if (d) digits = true;
|
|
136
|
+
const t = stripNum(v);
|
|
137
|
+
if (t.length >= 2 && hasLetters(t)) { out.push(t.slice(0, 60)); num.push(d); }
|
|
85
138
|
}
|
|
86
139
|
return { all: out, digits, num };
|
|
87
140
|
}
|
|
@@ -106,8 +159,12 @@
|
|
|
106
159
|
if (tagOf(i) === 'mat-icon' && !i.childElementCount) return norm(i.textContent);
|
|
107
160
|
if (tagOf(i) === 'img') return (i.getAttribute('src') || '').split('/').pop().split('?')[0];
|
|
108
161
|
const p = i.querySelector('use,path');
|
|
109
|
-
if (
|
|
110
|
-
|
|
162
|
+
if (p) return p.getAttribute('href') || p.getAttribute('xlink:href') || 'd:' + hash(p.getAttribute('d') || '');
|
|
163
|
+
// icon vẽ bằng hình cơ bản, không có path (ví dụ nút ⋮ gồm ba hình tròn): lấy dấu của chính các hình đó
|
|
164
|
+
const shapes = [...i.querySelectorAll('circle,rect,ellipse,line,polygon,polyline')].slice(0, 8);
|
|
165
|
+
if (!shapes.length) return '';
|
|
166
|
+
const A = ['cx', 'cy', 'r', 'x', 'y', 'width', 'height', 'rx', 'points', 'x1', 'y1', 'x2', 'y2'];
|
|
167
|
+
return 'g:' + hash(shapes.map(x => x.tagName + A.map(a => x.getAttribute(a) || '').join(',')).join('|'));
|
|
111
168
|
}
|
|
112
169
|
|
|
113
170
|
function compChain(e) {
|
|
@@ -201,7 +258,15 @@
|
|
|
201
258
|
if (getComputedStyle(e).cursor !== 'pointer') return target;
|
|
202
259
|
// leo lên mục bấm được nhỏ nhất: dừng khi cha gom nhiều mục có chữ (ví dụ cả thanh menu)
|
|
203
260
|
const textKids = p => { let n = 0; for (const c of p.children) if (norm(c.textContent)) n++; return n; };
|
|
204
|
-
for (let p = e.parentElement; p && p !== document.body && getComputedStyle(p).cursor === 'pointer' && textKids(p) <= 1; p = p.parentElement)
|
|
261
|
+
for (let p = e.parentElement; p && p !== document.body && getComputedStyle(p).cursor === 'pointer' && textKids(p) <= 1; p = p.parentElement) {
|
|
262
|
+
const b = p.getBoundingClientRect();
|
|
263
|
+
if (!b.width || !b.height) break; // thẻ bọc không có kích thước: tô sáng nó sẽ không thấy gì
|
|
264
|
+
if (b.width * b.height > 0.4 * innerWidth * innerHeight) break; // chiếm phần lớn màn hình: đó là vùng chứa, không phải nút
|
|
265
|
+
// cha còn chứa nút, ô nhập, icon hay ảnh khác: đó là một thanh gom nhiều thứ bấm được (thanh trên cùng, thanh bộ lọc), không phải một nút
|
|
266
|
+
const cur = e;
|
|
267
|
+
if ([...p.querySelectorAll('input,textarea,select,button,a[href],[role=button],img,svg,mat-icon,tcbs-icon')].some(x => !cur.contains(x) && !x.contains(cur) && visible(x))) break;
|
|
268
|
+
e = p;
|
|
269
|
+
}
|
|
205
270
|
return e;
|
|
206
271
|
}
|
|
207
272
|
|
|
@@ -211,63 +276,107 @@
|
|
|
211
276
|
for (let p = e, i = 0; p && p !== document.body && i < 6; p = p.parentElement, i++) out.push(tagOf(p) + (p.getAttribute('role') ? '[' + p.getAttribute('role') + ']' : ''));
|
|
212
277
|
return out.join('<');
|
|
213
278
|
};
|
|
214
|
-
|
|
279
|
+
const textOf = e => { const t = e.textContent || ''; return t.length > 400 ? '' : norm(t); };
|
|
280
|
+
// Nhãn "mềm": mẩu chữ trùng với một giá trị trong phản hồi API. Nó có thể là dữ liệu của riêng người dùng (tên, tên kế hoạch)
|
|
281
|
+
// hoặc là nhãn cố định mà API cũng trả về ("MUA", "Kỹ thuật"). Không biết chắc, nên: có trên phần tử thì cộng điểm,
|
|
282
|
+
// không có thì không trừ điểm.
|
|
283
|
+
function softHit(fp, e) {
|
|
284
|
+
if (!fp.soft || !fp.soft.length) return false;
|
|
285
|
+
const c = stripNum(textOf(e));
|
|
286
|
+
if (!c) return false;
|
|
287
|
+
for (const x of fp.soft) if (!c.includes(x)) return false;
|
|
288
|
+
return lettersLen(fp.soft.join('')) * 2 >= lettersLen(c); // khối chứa nhiều chữ khác thì không tính
|
|
289
|
+
}
|
|
290
|
+
// "cùng loại" với đích: cùng thẻ, cùng icon, cùng thuộc tính khoá, cùng nhãn. Dùng chung cho lúc ghi và lúc phát,
|
|
291
|
+
// để nhóm các phần tử lặp lại (các dòng của bảng) luôn được dựng theo đúng một cách.
|
|
292
|
+
function sameKind(fp, e) {
|
|
293
|
+
if (isUi(e) || !visible(e) || tagOf(e) !== fp.tag || iconOf(e) !== fp.icon) return false;
|
|
294
|
+
for (const k in fp.key) if (e.getAttribute(k) !== fp.key[k]) return false;
|
|
295
|
+
if (fp.soft && fp.soft.length) { if (!softHit(fp, e)) return false; }
|
|
296
|
+
else if (fp.label && labelScore(fp, e) < 0.9) return false;
|
|
297
|
+
// bản ghi từ v4 không đếm phần tử nằm ngoài màn vào nhóm; bản ghi cũ đã đếm cả chúng nên giữ nguyên cách đếm để thứ tự không lệch
|
|
298
|
+
return fp.v >= 4 ? !offscreen(e) : true;
|
|
299
|
+
}
|
|
300
|
+
// các phần tử khác cùng loại với đích (không tính phần tử bọc ngoài hay nằm trong đích)
|
|
215
301
|
function twinsOf(fp, el) {
|
|
216
302
|
const out = [];
|
|
217
|
-
for (const r of roots()) for (const e of r.querySelectorAll(fp.tag))
|
|
218
|
-
if (e === el || e.contains(el) || el.contains(e) || isUi(e) || !visible(e) || iconOf(e) !== fp.icon) continue;
|
|
219
|
-
if (fp.label && labelScore(fp, e) < 0.9) continue;
|
|
220
|
-
let same = true;
|
|
221
|
-
for (const k in fp.key) if (e.getAttribute(k) !== fp.key[k]) same = false;
|
|
222
|
-
if (same) out.push(e);
|
|
223
|
-
}
|
|
303
|
+
for (const r of roots()) for (const e of r.querySelectorAll(fp.tag)) if (e !== el && !e.contains(el) && !el.contains(e) && sameKind(fp, e)) out.push(e);
|
|
224
304
|
return out;
|
|
225
305
|
}
|
|
306
|
+
// Nhóm các phần tử cùng loại và cùng "hình dáng" vị trí, theo thứ tự trên trang. Khi nhiều lớp lồng nhau cùng khớp
|
|
307
|
+
// (khối bọc và chữ bên trong nó), chỉ giữ lớp ngoài cùng, để mỗi mục của danh sách được đếm đúng một lần.
|
|
308
|
+
function kindGroup(fp, sig) {
|
|
309
|
+
const all = [];
|
|
310
|
+
for (const r of roots()) for (const e of r.querySelectorAll(fp.tag)) if (sameKind(fp, e) && sigOf(e) === sig) all.push(e);
|
|
311
|
+
const set = new Set(all);
|
|
312
|
+
return all.filter(e => { for (let p = e.parentElement; p; p = p.parentElement) if (set.has(p)) return false; return true; });
|
|
313
|
+
}
|
|
226
314
|
|
|
315
|
+
// bản "trần" của dấu vân tay: bỏ phần chữ, chỉ còn cấu trúc (thẻ, icon, thuộc tính khoá). Dùng để dựng nhóm các dòng dữ liệu.
|
|
316
|
+
const bare = fp => Object.assign({}, fp, { soft: [], segs: [], label: '' });
|
|
317
|
+
const innerOf = (fp, g, sig) => [...g.querySelectorAll(fp.tag)].filter(x => sameKind(fp, x) && sigOf(x) === sig);
|
|
227
318
|
function capture(el) {
|
|
228
|
-
const fp = { v:
|
|
319
|
+
const fp = { v: 4, tag: tagOf(el), role: el.getAttribute('role') || '', label: '', segs: [], soft: [], exact: '', mixed: false, dyn: 0, cover: 1, key: {}, attr: {}, icon: iconOf(el), comp: compChain(el), anchor: '', anchorSkip: 0, pos: posOf(el), hint: '', ctx: context() };
|
|
229
320
|
// tiêu đề khối đứng trước: lấy mẩu đầu tiên không phải giá trị API, và nhớ vị trí của nó để lúc khớp đọc đúng mẩu đó
|
|
230
|
-
const ac = anchorCands(el), ai = ac.findIndex(v => !
|
|
321
|
+
const ac = anchorCands(el), ai = ac.findIndex((v, i) => !ac.data[i] && !isData(v));
|
|
231
322
|
if (ai >= 0) { fp.anchor = ac[ai]; fp.anchorSkip = ai; }
|
|
232
323
|
const attrLabel = el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || el.getAttribute('alt');
|
|
233
|
-
if (attrLabel) fp.label = stripNum(attrLabel).slice(0, 60);
|
|
234
|
-
else {
|
|
324
|
+
if (attrLabel && !isPersonal(attrLabel)) fp.label = stripNum(attrLabel).slice(0, 60);
|
|
325
|
+
else if (!attrLabel && !el.matches('mat-select,select,[role=combobox]')) { // ô chọn hiển thị giá trị đang chọn, không phải nhãn: bỏ qua chữ của nó
|
|
235
326
|
const p = pieces(el);
|
|
236
|
-
if (p
|
|
237
|
-
const stat =
|
|
327
|
+
if (p.all.length) {
|
|
328
|
+
const stat = [], soft = [];
|
|
329
|
+
for (const x of p.all) (isData(x) ? soft : stat).push(x);
|
|
238
330
|
const big = p.all.length > 2; // khối chứa nhiều chữ: chỉ giữ hai mẩu đầu (tiêu đề), phần sau thường là dữ liệu
|
|
239
331
|
fp.segs = stat.slice(0, big ? 2 : 5);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
fp.
|
|
243
|
-
fp.
|
|
332
|
+
if (!fp.segs.length) fp.soft = soft.slice(0, 2); // không có mẩu cố định nào: dùng mẩu "mềm" làm nhãn tuỳ chọn
|
|
333
|
+
const used = fp.segs.length ? fp.segs : fp.soft;
|
|
334
|
+
fp.label = used.join(' ').slice(0, 60);
|
|
335
|
+
fp.dyn = soft.length; // số mẩu chữ trùng giá trị API
|
|
336
|
+
fp.mixed = soft.length > 0 || p.digits || big;
|
|
337
|
+
fp.cover = +(lettersLen(used.join('')) / lettersLen(norm(el.textContent))).toFixed(2);
|
|
244
338
|
}
|
|
339
|
+
// chữ nguyên văn có con số ("1M", "Bước 2"): dùng để phân xử giữa các phần tử chỉ khác nhau ở con số
|
|
340
|
+
const own = textOf(el);
|
|
341
|
+
if (own && own.length <= 40 && /\d/.test(own) && hasLetters(own) && !isPersonal(own)) fp.exact = own;
|
|
245
342
|
}
|
|
246
|
-
|
|
343
|
+
// thuộc tính khoá: bỏ giá trị sinh tự động (mat-radio-group-0, mat-input-3, chuỗi có nhiều chữ số), vì mỗi lần tải một khác
|
|
344
|
+
for (const k of KEY) { const v = el.getAttribute(k); if (v && !/\d{3,}/.test(v) && !LIB.test(v) && !/[-_]\d+$/.test(v)) fp.key[k] = v; }
|
|
247
345
|
for (const k of SOFT) { const v = el.getAttribute(k); if (v && v.length <= 80) fp.attr[k] = k === 'href' ? v.split('?')[0] : stripNum(v); }
|
|
248
|
-
// số phần tử khác trên màn
|
|
346
|
+
// số phần tử khác trên màn cùng loại với đích (ví dụ ba nút bút chì, các nút MUA của bảng)
|
|
249
347
|
fp.twins = 0;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
348
|
+
{
|
|
349
|
+
// Phần tử có chữ, icon hoặc thuộc tính khoá: bản sao là mọi phần tử cùng loại trên màn.
|
|
350
|
+
// Phần tử không có gì để nhận diện (ô chỉ chứa số): bản sao là các phần tử cùng thẻ ở cùng "hình dáng" vị trí, tức các ô khác của lưới.
|
|
351
|
+
// Phần tử chỉ có chữ là dữ liệu (mã chứng khoán, tên quỹ): chữ đó không dùng để nhận diện được (người khác, ngày khác sẽ khác),
|
|
352
|
+
// nên cũng coi như không có gì để nhận diện: bản sao là các phần tử cùng cấu trúc, và sẽ ghi theo thứ tự.
|
|
353
|
+
const loose = !hasStrong(fp) && fp.soft.length > 0, base = loose ? bare(fp) : fp;
|
|
354
|
+
const twins = hasStrong(fp) ? twinsOf(fp, el) : kindGroup(base, sigOf(el)).filter(g => g !== el && !g.contains(el) && !el.contains(g));
|
|
355
|
+
fp.twins = twins.length;
|
|
356
|
+
if (twins.length) {
|
|
254
357
|
// Tiêu đề khối đứng trước chỉ phân biệt được các bản sao khi cả ba điều đúng:
|
|
255
358
|
// - nó nằm bên trong chính "đơn vị lặp" chứa đích (một dòng của bảng, một mục của form), không phải chữ của dòng bên cạnh;
|
|
256
359
|
// - nó là nhãn, không phải dữ liệu (mã, số liệu);
|
|
257
360
|
// - không bản sao nào khác có tiêu đề y hệt.
|
|
258
361
|
// Nếu không, đây là danh sách sinh từ dữ liệu: nhận diện bằng thứ tự, "nút thứ mấy trong danh sách".
|
|
259
|
-
const sig = sigOf(el), peer =
|
|
260
|
-
let byAnchor = !!fp.anchor;
|
|
362
|
+
const sig = sigOf(el), peer = twins.find(e => sigOf(e) === sig);
|
|
363
|
+
let byAnchor = !!fp.anchor && !loose;
|
|
261
364
|
if (byAnchor && peer) {
|
|
262
365
|
let unit = el;
|
|
263
366
|
while (unit.parentElement && !unit.parentElement.contains(peer)) unit = unit.parentElement;
|
|
264
367
|
const inner = anchorCands(el, unit), j = inner.indexOf(fp.anchor);
|
|
265
368
|
byAnchor = j >= 0 && !inner.data[j];
|
|
266
369
|
}
|
|
267
|
-
|
|
370
|
+
// (so với mọi mẩu chữ đứng trước bản sao, không chỉ mẩu ở cùng vị trí: các ô của một dòng thấy tên dòng ở những vị trí khác nhau)
|
|
371
|
+
if (byAnchor) byAnchor = !twins.slice(0, 300).some(e => anchorCands(e).some(v => sim(fp.anchor, v)));
|
|
268
372
|
if (!byAnchor) {
|
|
373
|
+
const group = kindGroup(base, sig);
|
|
269
374
|
fp.sig = sig;
|
|
270
|
-
fp.nth = group.
|
|
375
|
+
fp.nth = Math.max(0, group.findIndex(g => g === el || g.contains(el)));
|
|
376
|
+
if (loose) fp.loose = 1;
|
|
377
|
+
// đích nằm bên trong một mục của nhóm (khối bọc cùng "hình dáng"): nhớ nó là phần tử thứ mấy trong mục đó
|
|
378
|
+
const g = group[fp.nth];
|
|
379
|
+
if (g && g !== el && g.contains(el)) { const k = innerOf(base, g, sig).indexOf(el); if (k >= 0) fp.sub = k; }
|
|
271
380
|
fp.anchor = ''; fp.anchorSkip = 0;
|
|
272
381
|
}
|
|
273
382
|
}
|
|
@@ -293,7 +402,7 @@
|
|
|
293
402
|
if (!t) return 0;
|
|
294
403
|
// không giới hạn độ dài: thẻ có biểu đồ chứa rất nhiều chữ số nhưng vẫn là đích hợp lệ
|
|
295
404
|
if (t.length > 4000 && !t.includes(fp.segs[0].split(' ').sort((a, b) => b.length - a.length)[0])) return 0;
|
|
296
|
-
const c = norm(t);
|
|
405
|
+
const c = fp.v >= 3 ? stripNum(t) : norm(t);
|
|
297
406
|
let hit = 0;
|
|
298
407
|
for (const s of fp.segs) if (c.includes(s)) hit++;
|
|
299
408
|
if (hit < fp.segs.length) return hit * 2 >= fp.segs.length ? 0.5 * hit / fp.segs.length : 0;
|
|
@@ -305,7 +414,8 @@
|
|
|
305
414
|
function score(fp, e) {
|
|
306
415
|
let got = 0, max = 0;
|
|
307
416
|
const add = (w, v) => { max += w; got += w * (v === true ? 1 : v || 0); };
|
|
308
|
-
if (fp.
|
|
417
|
+
if (fp.soft && fp.soft.length) { if (softHit(fp, e)) add(W.label, 1); } // nhãn mềm: có thì cộng, không có thì không trừ
|
|
418
|
+
else if (fp.label) add(W.label, labelScore(fp, e));
|
|
309
419
|
for (const k in fp.key) add(W.key, e.getAttribute(k) === fp.key[k]);
|
|
310
420
|
for (const k in fp.attr) add(W.attr, softVal(e, k) === fp.attr[k]);
|
|
311
421
|
if (fp.icon) add(W.icon, iconOf(e) === fp.icon);
|
|
@@ -327,12 +437,13 @@
|
|
|
327
437
|
|
|
328
438
|
// có ít nhất một tín hiệu mạnh thì mới chấm đầy đủ
|
|
329
439
|
function strongHit(fp, e) {
|
|
330
|
-
if (fp.label && labelScore(fp, e)) return true;
|
|
440
|
+
if (fp.label && !(fp.soft && fp.soft.length) && labelScore(fp, e)) return true;
|
|
331
441
|
for (const k in fp.key) if (e.getAttribute(k) === fp.key[k]) return true;
|
|
332
442
|
if (fp.icon && iconOf(e) === fp.icon) return true;
|
|
333
443
|
return false;
|
|
334
444
|
}
|
|
335
|
-
|
|
445
|
+
// nhãn mềm không tính là tín hiệu mạnh: nó có thể vắng mặt ở người dùng khác
|
|
446
|
+
const hasStrong = fp => !!((fp.label && !(fp.soft && fp.soft.length)) || fp.icon || Object.keys(fp.key).length);
|
|
336
447
|
|
|
337
448
|
// hệ số "với tới được": bản sao nằm trong ngăn kéo ẩn ngoài màn hình hoặc bị che thì xếp sau bản đang hiện
|
|
338
449
|
function reach(e) {
|
|
@@ -364,9 +475,29 @@
|
|
|
364
475
|
if (fp.hint && !opts.noHint) {
|
|
365
476
|
let hits = [];
|
|
366
477
|
try { for (const r of rs) hits.push(...r.querySelectorAll(fp.hint)); } catch (_) { hits = []; }
|
|
367
|
-
hits = hits.filter(visible);
|
|
478
|
+
hits = hits.filter(h => visible(h) && !offscreen(h));
|
|
368
479
|
if (hits.length === 1) { const s = score(fp, hits[0]); if (s >= THRESHOLD) return done({ status: 'ok', el: hits[0], score: s, margin: 1, via: 'hint', scanned: 1 }); }
|
|
369
480
|
}
|
|
481
|
+
// Đích được ghi theo thứ tự trong danh sách. Dựng lại đúng nhóm như lúc ghi (cùng loại, cùng "hình dáng", lớp ngoài cùng),
|
|
482
|
+
// rồi: nếu trong nhóm chỉ đúng một phần tử có chữ nguyên văn như lúc ghi ("1M") thì lấy nó; nếu không thì lấy cái thứ nth.
|
|
483
|
+
if (fp.nth != null && fp.sig) {
|
|
484
|
+
const base = fp.loose ? bare(fp) : fp;
|
|
485
|
+
const group = kindGroup(base, fp.sig);
|
|
486
|
+
let same = fp.exact ? group.filter(g => textOf(g) === fp.exact) : [];
|
|
487
|
+
// dòng dữ liệu: nếu đúng một dòng còn mang chữ như lúc ghi (cùng mã) thì đi theo dòng đó, không thì lấy theo thứ tự
|
|
488
|
+
let byText = same.length === 1 ? fp.exact : '';
|
|
489
|
+
if (!byText && fp.loose) { same = group.filter(g => softHit(fp, g)); if (same.length === 1) byText = fp.soft.join(' '); }
|
|
490
|
+
const rep = byText ? same[0] : group[fp.nth];
|
|
491
|
+
if (!rep || offscreen(rep)) return done({ status: 'not-found', score: 0, scanned: group.length, need: fp.nth + 1, have: group.length });
|
|
492
|
+
// trong mục đó, trả về đúng lớp đã ghi (có thể là lớp bên trong)
|
|
493
|
+
let pick = rep, top = score(fp, rep);
|
|
494
|
+
if (fp.sub != null) { const x = innerOf(base, rep, fp.sig)[fp.sub]; if (x) { pick = x; top = score(fp, x); } }
|
|
495
|
+
else for (const x of rep.querySelectorAll(fp.tag)) if (sameKind(base, x)) { const v = score(fp, x); if (v > top) { pick = x; top = v; } }
|
|
496
|
+
// Lấy theo thứ tự mà số phần tử trong nhóm đã khác lúc ghi: danh sách đã đổi (thêm, bớt, một mục đang mở rộng), không còn chắc
|
|
497
|
+
// "thứ n" vẫn là mục cũ. Vẫn dẫn tới đó (danh sách sinh từ dữ liệu thì số dòng đổi là bình thường) nhưng báo ra để bên gọi biết.
|
|
498
|
+
const drift = !byText && fp.twins != null && group.length !== fp.twins + 1;
|
|
499
|
+
return done({ status: 'ok', el: pick, score: top, margin: 1, via: (byText ? 'chữ «' + byText + '»' : 'thứ ' + (fp.nth + 1) + '/' + group.length) + (drift ? ' (lúc ghi có ' + (fp.twins + 1) + ')' : ''), drift, scanned: group.length });
|
|
500
|
+
}
|
|
370
501
|
const strong = hasStrong(fp);
|
|
371
502
|
const ranked = [];
|
|
372
503
|
let scanned = 0;
|
|
@@ -382,22 +513,22 @@
|
|
|
382
513
|
ranked.sort((a, b) => b.score - a.score);
|
|
383
514
|
if (fp.tag && (!ranked.length || ranked[0].score < THRESHOLD)) { ranked.length = 0; scan('*'); ranked.sort((a, b) => b.score - a.score); }
|
|
384
515
|
// điểm gốc quyết định có đạt ngưỡng không; điểm đã nhân hệ số chỉ dùng để xếp hạng giữa các bản sao
|
|
516
|
+
// ứng viên đủ điểm nhưng nằm ngoài màn (ngăn kéo đang đóng) thì loại hẳn: chờ tới khi nó thật sự hiện ra
|
|
517
|
+
{ const live = ranked.filter(c => c.score < THRESHOLD || !offscreen(c.el)); ranked.length = 0; ranked.push(...live); }
|
|
385
518
|
for (const c of ranked) c.adj = c.score;
|
|
386
519
|
for (const c of ranked.slice(0, 8)) c.adj = c.score * reach(c.el);
|
|
387
520
|
ranked.sort((a, b) => b.adj - a.adj);
|
|
521
|
+
// Phân xử giữa các ứng viên ngang điểm: nếu có ứng viên mang đúng chữ nguyên văn lúc ghi (kể cả con số: "1M" chứ không phải "3M"),
|
|
522
|
+
// các ứng viên còn lại bị xếp sau. Không ứng viên nào mang đúng chữ đó (con số đã đổi) thì không ai bị trừ.
|
|
523
|
+
if (fp.exact) {
|
|
524
|
+
const hit = ranked.filter(c => c.score >= THRESHOLD && textOf(c.el) === fp.exact);
|
|
525
|
+
if (hit.length) { for (const c of ranked) if (!hit.includes(c)) c.adj *= 0.85; ranked.sort((a, b) => b.adj - a.adj); }
|
|
526
|
+
}
|
|
388
527
|
// ứng viên lồng nhau (nút và chữ bên trong nó) là cùng một đích, không tính là mơ hồ
|
|
389
528
|
let best = ranked[0];
|
|
390
529
|
// giữa một phần tử và khối cha của chính nó thì chọn theo điểm gốc, không để hệ số "bị che" quyết định
|
|
391
530
|
if (best) for (const c of ranked) if (c !== best && c.score > best.score && (c.el.contains(best.el) || best.el.contains(c.el))) best = c;
|
|
392
531
|
const second = best && ranked.find((c, i) => i > 0 && !best.el.contains(c.el) && !c.el.contains(best.el));
|
|
393
|
-
// đích được ghi theo thứ tự trong danh sách: lấy các ứng viên cùng "hình dáng", xếp theo thứ tự trên trang, chọn cái thứ nth
|
|
394
|
-
if (fp.nth != null && fp.sig) {
|
|
395
|
-
const group = ranked.filter(c => c.score >= THRESHOLD && sigOf(c.el) === fp.sig).map(c => c.el)
|
|
396
|
-
.sort((x, y) => (x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
|
397
|
-
const pick = group[fp.nth];
|
|
398
|
-
if (!pick) return done({ status: 'not-found', score: best ? best.score : 0, scanned, need: fp.nth + 1, have: group.length });
|
|
399
|
-
return done({ status: 'ok', el: pick, score: score(fp, pick), margin: 1, via: 'thứ ' + (fp.nth + 1) + '/' + group.length, scanned });
|
|
400
|
-
}
|
|
401
532
|
if (!best || best.score < THRESHOLD) return done({ status: 'not-found', score: best ? best.score : 0, scanned });
|
|
402
533
|
const margin = best.adj - (second ? second.adj : 0);
|
|
403
534
|
return done({ status: margin >= MARGIN ? 'ok' : 'ambiguous', el: best.el, score: best.score, margin, via: 'scan', scanned, top: ranked.slice(0, 3) });
|
|
@@ -407,9 +538,9 @@
|
|
|
407
538
|
function clickables() {
|
|
408
539
|
const out = new Set();
|
|
409
540
|
for (const r of roots()) {
|
|
410
|
-
for (const e of r.querySelectorAll(SEMANTIC)) if (visible(e) && !isUi(e)) out.add(e);
|
|
541
|
+
for (const e of r.querySelectorAll(SEMANTIC)) if (visible(e) && !isUi(e) && !offscreen(e)) out.add(e);
|
|
411
542
|
for (const e of r.querySelectorAll('div,span,li,td,tcbs-icon,mat-icon,img')) {
|
|
412
|
-
if (!visible(e) || isUi(e) || e.closest(SEMANTIC)) continue;
|
|
543
|
+
if (!visible(e) || isUi(e) || e.closest(SEMANTIC) || offscreen(e)) continue;
|
|
413
544
|
if (getComputedStyle(e).cursor === 'pointer' && getComputedStyle(e.parentElement).cursor !== 'pointer') out.add(e);
|
|
414
545
|
}
|
|
415
546
|
}
|
|
@@ -420,11 +551,13 @@
|
|
|
420
551
|
const steps = []; // { kind: 'click' | 'hover' | 'input' | 'info', fp, note }
|
|
421
552
|
let recording = false, playing = -1, raf = 0, target = null, token = 0, pending = null, lastCap = 0;
|
|
422
553
|
let hoverEl = null, hoverT = 0, lastDown = 0, popups = [], advancing = false;
|
|
554
|
+
let resumeAt = -1; // bước đang dở của lượt phát gần nhất (-1: không có)
|
|
423
555
|
let directRun = 0, fullSteps = null, choice = null; // lượt "vào thẳng" đang chạy; bản đầy đủ của tour khi chỉ chạy tới bước x; hai lựa chọn đang chờ
|
|
424
556
|
// khi đang ghi: menu bật ra do rê chuột (không phải do bấm) thì nhớ phần tử đang được rê lên
|
|
425
557
|
const popMo = new MutationObserver(list => {
|
|
426
|
-
//
|
|
427
|
-
|
|
558
|
+
// Thứ bật ra là do bấm (không phải do rê chuột) khi cú bấm xảy ra sau lúc chuột đi vào phần tử đang rê, tức chuột vẫn nằm
|
|
559
|
+
// trên chỗ vừa bấm; hoặc khi nó hiện ra gần như tức thì sau cú bấm. Rê sang một phần tử khác rồi menu mới bật ra thì là do rê.
|
|
560
|
+
if (!recording || performance.now() - lastDown < 250 || lastDown > hoverT || !hoverEl || !hoverEl.isConnected) return;
|
|
428
561
|
for (const m of list) for (const n of m.addedNodes) {
|
|
429
562
|
if (n.nodeType !== 1 || isUi(n) || !(n.closest('.cdk-overlay-container') || n.matches(POPUP))) continue;
|
|
430
563
|
// hộp thoại chặn màn hình và lớp nền mờ của nó không bao giờ là menu rê chuột
|
|
@@ -476,10 +609,15 @@
|
|
|
476
609
|
// chế độ chỉ phát (chạy thử từ trang quản trị, hoặc mở trang với ?tourfp=<mã>): không hiện thanh ghi tour
|
|
477
610
|
const PLAYER = !!window.TOURFP_PLAYER || new URLSearchParams(location.search).has('tourfp');
|
|
478
611
|
if (PLAYER) panel.style.display = 'none';
|
|
479
|
-
|
|
612
|
+
// Gắn giao diện vào trang. Khi thư viện được nạp trước cả lúc trang có thẻ <html> (chèn ở "document start"), chờ tới khi có.
|
|
613
|
+
const mount = () => document.documentElement.append(box, tip, panel);
|
|
614
|
+
if (document.documentElement) mount();
|
|
615
|
+
else new MutationObserver((_, mo) => { if (document.documentElement) { mo.disconnect(); mount(); } }).observe(document, { childList: true });
|
|
480
616
|
try { const p = JSON.parse(sessionStorage.getItem(POS) || 'null'); if (p) moveTo(p[0], p[1]); } catch (_) { /* vị trí cũ không đọc được */ }
|
|
481
617
|
|
|
482
|
-
const nameOf = fp => (fp.segs && fp.segs[0]) || fp.
|
|
618
|
+
const nameOf = fp => fp.exact || (fp.segs && fp.segs[0]) || (fp.soft && fp.soft[0]) || fp.label || fp.attr.placeholder
|
|
619
|
+
|| (fp.icon && !/^[dg]:/.test(fp.icon) && !/[\/#.]/.test(fp.icon) ? fp.icon : '') // tên icon đọc được ("close"), không phải mã băm hay đường dẫn
|
|
620
|
+
|| (fp.anchor ? 'nút ở mục ' + fp.anchor : 'phần tử được tô sáng');
|
|
483
621
|
// hiển thị chữ lên giao diện; phần đặt trong «…» (tên đích, tên tour) thành chữ in đậm, bỏ dấu ngoặc
|
|
484
622
|
const rich = (node, text) => {
|
|
485
623
|
node.replaceChildren();
|
|
@@ -520,21 +658,29 @@
|
|
|
520
658
|
tip.style.top = (below ? r.bottom + 12 : Math.max(8, r.top - tip.offsetHeight - 12)) + 'px';
|
|
521
659
|
}
|
|
522
660
|
}
|
|
523
|
-
|
|
661
|
+
let lastWatch = 0;
|
|
662
|
+
const loop = () => { place(); const t = performance.now(); if (t - lastWatch > 300) { lastWatch = t; watchStep(); } raf = requestAnimationFrame(loop); };
|
|
524
663
|
const flash = c => { const old = box.style.borderColor; box.style.borderColor = c; setTimeout(() => { box.style.borderColor = old; }, 250); };
|
|
525
664
|
|
|
526
665
|
// --- ghi: lấy dấu vân tay lúc nhấn xuống (trước khi app đổi DOM), ghi bước lúc bấm xong
|
|
527
666
|
const onMove = e => {
|
|
528
667
|
if (!recording || isUi(e.target)) return;
|
|
529
668
|
target = clickableRoot(e.target);
|
|
530
|
-
|
|
669
|
+
noteHover(e.target, target);
|
|
531
670
|
};
|
|
671
|
+
// Ghi nhận phần tử đang được rê lên. Gọi cả ở mouseover: sự kiện này đến TRƯỚC mouseenter của app, nên khi menu rê chuột bật ra
|
|
672
|
+
// ngay trong mouseenter thì bộ theo dõi popup đã biết chuột đang ở phần tử nào (mousemove đến sau, lúc đó đã muộn).
|
|
673
|
+
function noteHover(raw, root) {
|
|
674
|
+
if (!raw.closest || raw.closest(POPUP) || hoverEl === root) return;
|
|
675
|
+
hoverEl = root; hoverT = performance.now();
|
|
676
|
+
}
|
|
677
|
+
const onOver = e => { if (recording && !isUi(e.target)) noteHover(e.target, clickableRoot(e.target)); };
|
|
532
678
|
const onDown = e => {
|
|
533
679
|
if (isUi(e.target)) return;
|
|
534
680
|
lastDown = performance.now();
|
|
535
681
|
if (recording) {
|
|
536
682
|
const el = clickableRoot(e.target);
|
|
537
|
-
pending = { el, fp: capture(el) };
|
|
683
|
+
pending = { el, fp: capture(el), x: e.clientX, y: e.clientY, t: performance.now(), alt: e.altKey, clickable: isClickable(el) };
|
|
538
684
|
if (e.altKey) e.stopImmediatePropagation();
|
|
539
685
|
} else gate(e, false);
|
|
540
686
|
};
|
|
@@ -546,28 +692,39 @@
|
|
|
546
692
|
const el = clickableRoot(e.target);
|
|
547
693
|
const fp = pending && (pending.el === el || pending.el.contains(el) || el.contains(pending.el)) ? pending.fp : capture(el);
|
|
548
694
|
pending = null;
|
|
549
|
-
// phần tử không bấm được (tiêu đề, thẻ thông tin) thì là bước "chỉ tô sáng", khi phát có nút Tiếp
|
|
550
|
-
const kind = e.altKey || !isClickable(el) ? 'info' : el.matches('input,textarea') ? 'input' : 'click';
|
|
551
695
|
if (e.altKey) { e.preventDefault(); e.stopImmediatePropagation(); }
|
|
696
|
+
commit(el, fp, e.altKey);
|
|
697
|
+
};
|
|
698
|
+
// thêm một bước vào tour
|
|
699
|
+
// acted = true: cú bấm đã được app xử lý ngay lúc nhấn xuống (phần tử có thể đã bị gỡ), nên chắc chắn là bước "bấm"
|
|
700
|
+
function commit(el, fp, alt, acted) {
|
|
701
|
+
// phần tử không bấm được (tiêu đề, thẻ thông tin) thì là bước "chỉ tô sáng", khi phát có nút Tiếp
|
|
702
|
+
// Phần tử trông không bấm được (không có con trỏ tay) vẫn có thể được app gắn sự kiện bấm: cứ ghi là "bấm" rồi theo dõi,
|
|
703
|
+
// không thấy tác dụng gì thì mới hạ thành "chỉ tô sáng" (khi phát có nút Tiếp).
|
|
704
|
+
const kind = alt ? 'info' : acted ? 'click' : el.matches('input,textarea') ? 'input' : 'click';
|
|
552
705
|
// đích nằm trong menu bật ra do rê chuột: chèn bước "rê chuột" vào trước
|
|
553
706
|
const pop = popups.find(p => !p.used && p.node.isConnected && p.node.contains(el));
|
|
554
707
|
if (pop) { for (const p of popups) if (p.node.contains(el) || pop.node.contains(p.node)) p.used = true; steps.push({ kind: 'hover', fp: pop.trig, note: '' }); }
|
|
555
708
|
const step = { kind, fp, note: '' };
|
|
556
709
|
steps.push(step);
|
|
557
|
-
if (kind === 'click') watchEffect(step);
|
|
710
|
+
if (kind === 'click' && !acted) watchEffect(step, !isClickable(el));
|
|
711
|
+
if (acted) setTimeout(() => { step.after = context(); }, 700); // thay đổi đã xảy ra rồi: chỉ cần ghi lại màn hình sau cú bấm
|
|
558
712
|
flash('#3dd68c'); setInfo();
|
|
559
|
-
}
|
|
713
|
+
}
|
|
560
714
|
const isClickable = el => el.matches(SEMANTIC) || getComputedStyle(el).cursor === 'pointer';
|
|
561
715
|
// cú bấm không làm đổi màn và không làm trang thay đổi gì thì hạ thành bước "chỉ tô sáng"
|
|
562
716
|
const STATE_CLASS = /check|select|activ|expand|open|show|collaps/i;
|
|
563
|
-
|
|
717
|
+
// strict: phần tử trông không bấm được. Trang có số liệu nhảy liên tục, nên chỉ tin các thay đổi rõ ràng: màn hình đổi,
|
|
718
|
+
// trạng thái chọn/mở đổi, lớp phủ bật ra, hoặc cả một khối được vẽ thêm hay gỡ đi. Thay đổi lặt vặt và việc cuộn thì không tính.
|
|
719
|
+
function watchEffect(step, strict) {
|
|
564
720
|
const before = context();
|
|
565
721
|
let changed = false;
|
|
722
|
+
const bigNode = (n, m) => !strict || !!(m.target.closest && m.target.closest('.cdk-overlay-container')) || n.matches(POPUP) || n.querySelectorAll('*').length >= 3;
|
|
566
723
|
const mo = new MutationObserver(list => {
|
|
567
724
|
for (const m of list) {
|
|
568
725
|
if (isUi(m.target)) continue;
|
|
569
726
|
if (m.type === 'childList') {
|
|
570
|
-
for (const n of [...m.addedNodes, ...m.removedNodes]) if (n.nodeType === 1 && !isUi(n) && !/ripple/i.test(n.className && n.className.baseVal === undefined ? n.className : '')) changed = true;
|
|
727
|
+
for (const n of [...m.addedNodes, ...m.removedNodes]) if (n.nodeType === 1 && !isUi(n) && !/ripple/i.test(n.className && n.className.baseVal === undefined ? n.className : '') && bigNode(n, m)) changed = true;
|
|
571
728
|
} else if (m.attributeName === 'class') {
|
|
572
729
|
const a = new Set((m.oldValue || '').split(/\s+/)), b = m.target.classList;
|
|
573
730
|
for (const c of b) if (!a.has(c) && STATE_CLASS.test(c)) changed = true;
|
|
@@ -576,19 +733,41 @@
|
|
|
576
733
|
}
|
|
577
734
|
});
|
|
578
735
|
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['class', 'aria-checked', 'aria-selected', 'aria-expanded', 'aria-pressed'] });
|
|
736
|
+
const onScroll = () => { if (!strict) changed = true; }; // cú bấm làm trang cuộn tới một mục cũng là có tác dụng
|
|
737
|
+
document.addEventListener('scroll', onScroll, true);
|
|
579
738
|
setTimeout(() => {
|
|
580
|
-
mo.disconnect();
|
|
739
|
+
mo.disconnect(); document.removeEventListener('scroll', onScroll, true);
|
|
581
740
|
step.after = context(); // màn hình sau cú bấm; ở bước cuối, đây là "màn cuối" của tour
|
|
582
741
|
if (!changed && sameCtx(step.after, before)) { step.kind = 'info'; setInfo(); }
|
|
583
742
|
}, 700);
|
|
584
743
|
}
|
|
585
|
-
const onUp = e => {
|
|
744
|
+
const onUp = e => {
|
|
745
|
+
if (isUi(e.target)) return;
|
|
746
|
+
if (!recording) return gate(e, false);
|
|
747
|
+
if (e.altKey) e.stopImmediatePropagation();
|
|
748
|
+
// Nhả chuột tại chỗ mà không có sự kiện click theo sau (app đã xử lý lúc nhấn xuống rồi gỡ phần tử đi): vẫn ghi thành một bước,
|
|
749
|
+
// bằng dấu vân tay đã lấy lúc nhấn xuống. Kéo thả hay cuộn (chuột đã di chuyển) thì không ghi.
|
|
750
|
+
if (e.type !== 'pointerup' || !pending) return;
|
|
751
|
+
const p = pending;
|
|
752
|
+
if (Math.hypot(e.clientX - p.x, e.clientY - p.y) > 8 || performance.now() - p.t > 800) return;
|
|
753
|
+
setTimeout(() => {
|
|
754
|
+
if (!recording || pending !== p) return;
|
|
755
|
+
pending = null; lastCap = performance.now();
|
|
756
|
+
// chỉ coi là "đã được xử lý lúc nhấn xuống" khi phần tử vốn bấm được và giờ đã biến mất khỏi trang
|
|
757
|
+
commit(p.el, p.fp, p.alt, p.clickable && !p.el.isConnected);
|
|
758
|
+
}, 150);
|
|
759
|
+
};
|
|
586
760
|
|
|
587
761
|
// --- phát: chỉ cho bấm vào đích; bấm đúng đích ở bước "thao tác" thì sang bước sau
|
|
588
762
|
function gate(e, isClick) {
|
|
589
763
|
if (playing < 0 || !target) return;
|
|
590
764
|
const st = steps[playing], inside = target.contains(e.target);
|
|
591
|
-
|
|
765
|
+
// một hộp thoại không chứa đích đang che màn hình (quảng cáo, thông báo): để người dùng thao tác trong nó mà đóng đi
|
|
766
|
+
const d = topDialog();
|
|
767
|
+
if (d && !d.contains(target) && e.target.closest && (d.contains(e.target) || e.target.closest('.cdk-overlay-container'))) return;
|
|
768
|
+
// bước "chỉ tô sáng": không chặn cú bấm vào chính đích (nếu nó có tác dụng thì bộ theo dõi bên dưới sẽ dẫn tiếp), nhưng không tự sang bước
|
|
769
|
+
if (inside && st.kind === 'info') return;
|
|
770
|
+
if (inside && st.kind !== 'hover') { if (isClick && st.kind === 'click') { const i = playing; advancing = true; setTimeout(() => { if (playing === i) show(i + 1); }, 300); } return; }
|
|
592
771
|
e.stopImmediatePropagation();
|
|
593
772
|
if (isClick) { e.preventDefault(); flash('#fff'); }
|
|
594
773
|
}
|
|
@@ -602,12 +781,13 @@
|
|
|
602
781
|
}
|
|
603
782
|
function stop() { recording = false; pending = null; popMo.disconnect(); bRec.textContent = '● Ghi'; bRec.style.background = '#2a2f3a'; target = null; box.style.display = 'none'; cancelAnimationFrame(raf); setInfo(); }
|
|
604
783
|
function stopPlay(msg) {
|
|
605
|
-
token++; directRun++; playing = -1; atEnd = false; choice = null; bClose.style.display = 'none';
|
|
784
|
+
token++; directRun++; playing = -1; atEnd = false; choice = null; resumeAt = -1; bClose.style.display = 'none';
|
|
606
785
|
if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
|
|
607
786
|
tip.style.display = 'none'; box.style.display = 'none'; target = null; cancelAnimationFrame(raf); setInfo(msg);
|
|
608
787
|
}
|
|
609
788
|
|
|
610
|
-
|
|
789
|
+
const sure = fp => find(fp).status === 'ok'; // tìm thấy và không mơ hồ
|
|
790
|
+
// chờ đến khi màn hình của bước hiện ra và tìm được đích (kết quả mơ hồ không tính là tìm được)
|
|
611
791
|
// alt: dấu vân tay của bước kế; nếu màn hình đã ở trạng thái của bước kế thì bỏ qua bước này
|
|
612
792
|
// alts: các bước kế tiếp [{fp, idx}]. Nếu đích đang chờ không xuất hiện mà đích của một bước sau đã hiện ổn định
|
|
613
793
|
// (bước này đã được làm sẵn, ví dụ menu đã mở rộng từ trước) thì trả về { skipTo: idx }.
|
|
@@ -622,10 +802,10 @@
|
|
|
622
802
|
function tryIt() {
|
|
623
803
|
if (my !== token) return fin(null);
|
|
624
804
|
last = find(fp);
|
|
625
|
-
if (last.
|
|
805
|
+
if (last.status === 'ok') return fin(last);
|
|
626
806
|
if (alts) {
|
|
627
807
|
let hit = -1;
|
|
628
|
-
for (const a of alts) { const r = find(a.fp); if (r.
|
|
808
|
+
for (const a of alts) { const r = find(a.fp); if (r.status === 'ok' && (!menuOnly || menuOf(r.el))) { hit = a.idx; break; } }
|
|
629
809
|
if (hit < 0) seenIdx = -1;
|
|
630
810
|
else if (hit !== seenIdx) { seenIdx = hit; seenT = performance.now(); }
|
|
631
811
|
else if (performance.now() - seenT >= (menuOnly ? 400 : 1500)) return fin({ skipTo: hit });
|
|
@@ -638,12 +818,30 @@
|
|
|
638
818
|
});
|
|
639
819
|
}
|
|
640
820
|
|
|
821
|
+
// hộp thoại đang che màn hình mà không bước nào của tour diễn ra trong nó (quảng cáo, thông báo bất chợt)
|
|
822
|
+
const foreignDialog = () => { const d = topDialog(); if (!d) return false; const k = dialogKey(d); return !steps.some(s => s.fp.ctx.dialog === k); };
|
|
823
|
+
// Tạm ngừng dẫn cho tới khi người dùng đóng hộp thoại lạ, rồi phát tiếp từ đúng chỗ. Không chặn cú bấm nào, không tải lại trang.
|
|
824
|
+
function holdForDialog() {
|
|
825
|
+
const my = ++token;
|
|
826
|
+
playing = -1; target = null; box.style.display = 'none'; cancelAnimationFrame(raf);
|
|
827
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
|
|
828
|
+
rich(tipText, 'Hãy đóng hộp thoại đang che màn hình, tour sẽ tự tiếp tục.'); tipMeta.textContent = ''; setBtns(null, 'Dừng');
|
|
829
|
+
setInfo('Chờ đóng hộp thoại');
|
|
830
|
+
const timer = setInterval(() => { if (my !== token) return clearInterval(timer); if (!foreignDialog()) { clearInterval(timer); play(); } }, 300);
|
|
831
|
+
}
|
|
832
|
+
// Trong lúc đang tô sáng một đích: màn hình đổi dưới chân (hộp thoại bật lên, đích bị gỡ hay trượt ra ngoài) thì tìm lại.
|
|
833
|
+
function watchStep() {
|
|
834
|
+
if (playing < 0 || !target || advancing || recording) return;
|
|
835
|
+
const i = playing, st = steps[i];
|
|
836
|
+
if (foreignDialog()) return holdForDialog();
|
|
837
|
+
if (!target.isConnected || !visible(target) || offscreen(target) || context().dialog !== st.fp.ctx.dialog) show(i);
|
|
838
|
+
}
|
|
641
839
|
async function show(i) {
|
|
642
840
|
if (i >= steps.length) return stopPlay(`Xong tour · ${steps.length} bước`);
|
|
643
841
|
playing = i; target = null; advancing = false; box.style.display = 'none';
|
|
644
|
-
const st = steps[i], nx = steps[i + 1], name = nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
|
|
842
|
+
const st = steps[i], nx = steps[i + 1], name = st.title || nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
|
|
645
843
|
// bước rê chuột mà đích của bước sau đã hiện sẵn thì không cần rê nữa
|
|
646
|
-
if (st.kind === 'hover' && nx &&
|
|
844
|
+
if (st.kind === 'hover' && nx && sure(nx.fp)) return show(i + 1);
|
|
647
845
|
box.style.borderColor = '#e5484d'; box.style.boxShadow = '0 0 0 9999px rgba(0,0,0,.5)';
|
|
648
846
|
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
|
|
649
847
|
// nút Tiếp chỉ có ở bước nhập và bước chỉ tô sáng; bước cần bấm hoặc rê chuột thì người dùng phải tự làm
|
|
@@ -655,15 +853,21 @@
|
|
|
655
853
|
if (!r || playing !== i) return;
|
|
656
854
|
if (r.skipTo != null) return show(r.skipTo);
|
|
657
855
|
setInfo(`${head}: ${r.status}`);
|
|
658
|
-
if (
|
|
659
|
-
rich(tipText,
|
|
856
|
+
if (r.status !== 'ok') {
|
|
857
|
+
rich(tipText, r.status === 'ambiguous'
|
|
858
|
+
? `${head}: có nhiều phần tử giống «${name}» nên chưa xác định chắc được đích`
|
|
859
|
+
: `${head}: không tìm thấy «${name}»` + (r.expected ? `\nCần ở màn: ${r.expected.path} ${r.expected.dialog} ${r.expected.step}` : ''));
|
|
660
860
|
tipMeta.textContent = r.status;
|
|
861
|
+
// không bỏ cuộc: tiếp tục theo dõi, đích hiện ra sau (người dùng tự mở menu, dữ liệu tải xong) thì dẫn tiếp
|
|
862
|
+
const again = await waitFind(st.fp, 600000, after(i + 1, 2));
|
|
863
|
+
if (again && playing === i) { if (again.skipTo != null) show(again.skipTo); else if (again.status === 'ok') show(i); }
|
|
661
864
|
return;
|
|
662
865
|
}
|
|
866
|
+
resumeAt = i;
|
|
663
867
|
target = r.el;
|
|
664
868
|
r.el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
665
869
|
rich(tipText, `${head}: ` + (st.note || (st.kind === 'click' ? `Bấm vào «${name}» để tiếp tục` : st.kind === 'hover' ? `Rê chuột vào «${name}»` : st.kind === 'input' ? `Nhập vào ô «${name}», xong bấm Tiếp` : name)));
|
|
666
|
-
tipMeta.textContent = `khớp ${Math.round(r.score * 100)}% · ${r.via} · ${r.ms} ms
|
|
870
|
+
tipMeta.textContent = `khớp ${Math.round(r.score * 100)}% · ${r.via} · ${r.ms} ms`;
|
|
667
871
|
// bước cuối: nút thành "Kết thúc". Bước có nút Tiếp thì chỉ còn một nút Kết thúc; bước phải tự bấm thì nút Dừng đổi tên.
|
|
668
872
|
const lastStep = i === steps.length - 1, manual = st.kind === 'input' || st.kind === 'info';
|
|
669
873
|
if (lastStep) setBtns(manual ? 'Kết thúc' : null, manual ? null : 'Kết thúc');
|
|
@@ -671,7 +875,7 @@
|
|
|
671
875
|
// bước rê chuột: sang bước sau ngay khi đích của bước sau hiện ra
|
|
672
876
|
if (st.kind === 'hover' && nx) {
|
|
673
877
|
const r2 = await waitFind(nx.fp, 120000, after(i + 2, 2), true);
|
|
674
|
-
if (r2 && playing === i) { if (r2.skipTo != null) show(r2.skipTo); else if (r2.
|
|
878
|
+
if (r2 && playing === i) { if (r2.skipTo != null) show(r2.skipTo); else if (r2.status === 'ok') show(i + 1); }
|
|
675
879
|
}
|
|
676
880
|
}
|
|
677
881
|
// bước đầu tiên thuộc màn đang đứng (cùng đường dẫn, hộp thoại, bước); ưu tiên bước đã tìm thấy đích
|
|
@@ -682,7 +886,7 @@
|
|
|
682
886
|
for (let i = 0; i < steps.length; i++) {
|
|
683
887
|
if (!sameCtx(steps[i].fp.ctx, c)) continue;
|
|
684
888
|
if (first < 0) first = i;
|
|
685
|
-
if (
|
|
889
|
+
if (sure(steps[i].fp)) return i;
|
|
686
890
|
}
|
|
687
891
|
return first;
|
|
688
892
|
}
|
|
@@ -720,18 +924,22 @@
|
|
|
720
924
|
if (recording) stop();
|
|
721
925
|
if (!steps.length) return setInfo('Chưa có bước nào');
|
|
722
926
|
atEnd = false;
|
|
927
|
+
// có hộp thoại lạ đang che màn hình: chờ người dùng đóng nó trước, không vội về màn bắt đầu hay tải lại trang
|
|
928
|
+
if (foreignDialog()) return holdForDialog();
|
|
723
929
|
const c = context(), n = steps.length - 1, last = steps[n];
|
|
930
|
+
// lượt trước đang dở ở một bước mà đích của nó giờ đã có trên màn hình: đi tiếp từ bước đó, không quay về bước đầu
|
|
931
|
+
if (resumeAt > 0 && resumeAt <= n && sameCtx(steps[resumeAt].fp.ctx, c) && sure(steps[resumeAt].fp)) return show(resumeAt);
|
|
724
932
|
let first = -1;
|
|
725
933
|
for (let i = 0; i <= n; i++) {
|
|
726
934
|
if (!sameCtx(steps[i].fp.ctx, c)) continue;
|
|
727
935
|
if (first < 0) first = i;
|
|
728
|
-
if (
|
|
936
|
+
if (sure(steps[i].fp)) return show(i);
|
|
729
937
|
}
|
|
730
938
|
// không bước nào tìm thấy đích: nếu màn này là màn của (hoặc sau) bước cuối thì coi như đã ở cuối tour
|
|
731
939
|
if ((last.after && sameCtx(last.after, c)) || sameCtx(last.fp.ctx, c)) {
|
|
732
|
-
const r = sameCtx(last.fp.ctx, c) ? await waitFind(last.fp, 1500) : {
|
|
940
|
+
const r = sameCtx(last.fp.ctx, c) ? await waitFind(last.fp, 1500) : { status: 'not-found' }; // chờ một nhịp phòng khi màn chưa vẽ xong
|
|
733
941
|
if (!r) return;
|
|
734
|
-
return r.
|
|
942
|
+
return r.status === 'not-found' ? endNotice() : show(n); // 'ambiguous': show() sẽ báo chưa xác định chắc được đích
|
|
735
943
|
}
|
|
736
944
|
if (first >= 0) show(first); else goStart();
|
|
737
945
|
}
|
|
@@ -773,7 +981,7 @@
|
|
|
773
981
|
if (steps[first].fp.ctx.dialog) return play(); // chặng cuối bắt đầu trong một hộp thoại: không tới được bằng đường dẫn, quay về dẫn từng bước
|
|
774
982
|
const c = context();
|
|
775
983
|
let start = -1;
|
|
776
|
-
for (let i = first; i < n && start < 0; i++) if (sameCtx(steps[i].fp.ctx, c) &&
|
|
984
|
+
for (let i = first; i < n && start < 0; i++) if (sameCtx(steps[i].fp.ctx, c) && sure(steps[i].fp)) start = i; // đang đứng sẵn giữa chặng
|
|
777
985
|
if (start < 0) {
|
|
778
986
|
if (topDialog()) pressEsc();
|
|
779
987
|
if (location.pathname !== steps[first].fp.ctx.path) navTo(steps[first].fp.ctx.path);
|
|
@@ -786,7 +994,7 @@
|
|
|
786
994
|
const r = await waitFind(st.fp, 6000, steps.slice(i + 1, i + 3).map((x, k) => ({ fp: x.fp, idx: i + 1 + k })));
|
|
787
995
|
if (!r || !alive()) return;
|
|
788
996
|
if (r.skipTo != null) { i = r.skipTo - 1; continue; } // bước này đã được làm sẵn
|
|
789
|
-
if (
|
|
997
|
+
if (r.status !== 'ok' || st.kind === 'input') { directRun++; return show(i); } // không tự làm được (không thấy đích, hoặc cần người dùng nhập): dẫn từng bước từ đây
|
|
790
998
|
if (st.kind === 'hover') hoverOn(r.el);
|
|
791
999
|
else { r.el.scrollIntoView({ block: 'center', inline: 'nearest' }); r.el.click(); }
|
|
792
1000
|
await sleep(350);
|
|
@@ -805,10 +1013,45 @@
|
|
|
805
1013
|
return opts.mode === 'direct' ? direct() : play();
|
|
806
1014
|
}
|
|
807
1015
|
|
|
1016
|
+
// Dùng cho kiểm thử tự động: tìm đích của bước thứ i (chờ tối đa opts.timeout), cuộn nó vào tầm nhìn và trả về toạ độ.
|
|
1017
|
+
// Không tô sáng, không chặn cú bấm, không tự bấm: trình điều khiển bên ngoài dùng toạ độ này để rê hoặc bấm bằng chuột thật.
|
|
1018
|
+
async function locate(i, opts = {}) {
|
|
1019
|
+
const st = steps[i];
|
|
1020
|
+
if (!st) return { index: i, status: 'no-step' };
|
|
1021
|
+
const t0 = performance.now();
|
|
1022
|
+
const r = await waitFind(st.fp, opts.timeout || 8000, steps.slice(i + 1, i + 3).map((x, k) => ({ fp: x.fp, idx: i + 1 + k })));
|
|
1023
|
+
const out = { index: i, kind: st.kind, name: st.title || nameOf(st.fp), waited: Math.round(performance.now() - t0), context: context(), expected: st.fp.ctx };
|
|
1024
|
+
if (!r) return { ...out, status: 'cancelled' };
|
|
1025
|
+
if (r.skipTo != null) return { ...out, status: 'skipped', skipTo: r.skipTo }; // bước này đã được làm sẵn
|
|
1026
|
+
if (r.status !== 'ok') return { ...out, status: r.status, score: +(r.score || 0).toFixed(3) }; // gồm cả 'ambiguous': không trả toạ độ, để không ai bấm nhầm
|
|
1027
|
+
r.el.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
1028
|
+
await sleep(150);
|
|
1029
|
+
const b = r.el.getBoundingClientRect();
|
|
1030
|
+
return { ...out, status: r.status, score: +r.score.toFixed(3), via: r.via, drift: !!r.drift, ms: r.ms, rect: { x: b.left, y: b.top, w: b.width, h: b.height } };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
808
1033
|
// ---------- máy chủ lưu tour (thư mục server/) ----------
|
|
809
1034
|
const SERVER = String(window.TOURFP_SERVER || 'http://localhost:8000').replace(/\/$/, '');
|
|
810
1035
|
// Chép vào clipboard NGAY trong cú bấm. Nhiều trang (kể cả TCInvest) từ chối quyền ghi clipboard kiểu mới,
|
|
811
1036
|
// nên chỉ còn cách cũ, và cách cũ chỉ chạy được khi đang ở trong một cú bấm của người dùng.
|
|
1037
|
+
// Lúc ghi, thư viện có thể chưa nghe được hết phản hồi API nên một tiêu đề neo là dữ liệu (tên khách hàng, mã) có thể lọt vào.
|
|
1038
|
+
// Trước khi tour rời khỏi trang (lưu, chép, xuất) thì rà lại và bỏ các tiêu đề neo giờ đã biết là giá trị API.
|
|
1039
|
+
function scrub() {
|
|
1040
|
+
let n = 0;
|
|
1041
|
+
readStores(); // app có thể vừa lưu hồ sơ sau lúc thư viện khởi động
|
|
1042
|
+
for (const st of steps) {
|
|
1043
|
+
const fp = st.fp;
|
|
1044
|
+
if (!fp) continue;
|
|
1045
|
+
if (fp.anchor && isData(fp.anchor)) { fp.anchor = ''; fp.anchorSkip = 0; n++; }
|
|
1046
|
+
// chữ nguyên văn và nhãn lấy từ thuộc tính (title, aria-label) cũng có thể là tên người: bỏ, phần tử vẫn còn các tín hiệu khác
|
|
1047
|
+
if (fp.exact && isPersonal(fp.exact)) { fp.exact = ''; n++; }
|
|
1048
|
+
if (fp.label && isPersonal(fp.label)) {
|
|
1049
|
+
// nhãn là dữ liệu cá nhân: xoá chữ đi, chỉ giữ cấu trúc. Phần tử sẽ được tìm theo icon, thuộc tính, vị trí.
|
|
1050
|
+
fp.label = ''; fp.segs = []; fp.soft = []; if (fp.loose) delete fp.loose; n++;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return n;
|
|
1054
|
+
}
|
|
812
1055
|
function copyNow(text) {
|
|
813
1056
|
const ta = mk('textarea', 'position:fixed;left:0;top:0;opacity:0');
|
|
814
1057
|
ta.value = text; document.documentElement.append(ta); ta.select();
|
|
@@ -828,6 +1071,7 @@
|
|
|
828
1071
|
// Gọi được máy chủ: hỏi tên rồi gửi tour lên. Không gọi được: nút thành "Chép JSON" và chép ngay trong cú bấm,
|
|
829
1072
|
// để dán vào hộp Thêm tour của trang quản trị.
|
|
830
1073
|
async function save() {
|
|
1074
|
+
scrub();
|
|
831
1075
|
if (recording) stop();
|
|
832
1076
|
if (!steps.length) return setInfo('Chưa có bước nào để lưu');
|
|
833
1077
|
if (serverOk === false) {
|
|
@@ -882,7 +1126,7 @@
|
|
|
882
1126
|
bNext.onclick = () => { if (choice) pick('guide'); else if (atEnd) { atEnd = false; goStart(); } else show(playing + 1); };
|
|
883
1127
|
bStop.onclick = () => { if (choice) pick('direct'); else stopPlay(); };
|
|
884
1128
|
bClose.onclick = () => stopPlay();
|
|
885
|
-
const L = [['mousemove', onMove], ['pointerdown', onDown], ['mousedown', onUp], ['pointerup', onUp], ['mouseup', onUp], ['click', onClick]];
|
|
1129
|
+
const L = [['mousemove', onMove], ['mouseover', onOver], ['pointerdown', onDown], ['mousedown', onUp], ['pointerup', onUp], ['mouseup', onUp], ['click', onClick]];
|
|
886
1130
|
for (const [n, h] of L) document.addEventListener(n, h, true);
|
|
887
1131
|
|
|
888
1132
|
function destroy() {
|
|
@@ -893,13 +1137,17 @@
|
|
|
893
1137
|
}
|
|
894
1138
|
|
|
895
1139
|
window.TourFP = {
|
|
896
|
-
capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run, start, direct, ask,
|
|
1140
|
+
capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run, start, direct, ask, locate,
|
|
897
1141
|
showPanel: (on = true) => { panel.style.display = on ? 'flex' : 'none'; }, find, score, context, clickables, record, stop, play, destroy, steps,
|
|
898
1142
|
apiStats: () => ({ responses: api.responses, values: api.set.size }),
|
|
899
1143
|
isApiValue,
|
|
900
1144
|
state: () => ({ recording, playing, steps: steps.length, target: target ? nameOf(capture(target)) : null, tip: tip.style.display === 'block' ? tipText.textContent : '' }),
|
|
901
|
-
export: () => JSON.stringify(steps, null, 2),
|
|
902
|
-
|
|
1145
|
+
export: () => { scrub(); return JSON.stringify(steps, null, 2); },
|
|
1146
|
+
// tour nạp từ ngoài có thể đã bị lược các trường rỗng: điền lại giá trị mặc định để việc so khớp không lệch hay ném lỗi
|
|
1147
|
+
load: a => {
|
|
1148
|
+
for (const st of a) if (st && st.fp) st.fp = Object.assign({ tag: '', role: '', label: '', segs: [], soft: [], exact: '', key: {}, attr: {}, icon: '', comp: [], anchor: '' }, st.fp);
|
|
1149
|
+
fullSteps = null; steps.length = 0; steps.push(...a); setInfo();
|
|
1150
|
+
},
|
|
903
1151
|
};
|
|
904
1152
|
|
|
905
1153
|
// vừa bị tải lại trang giữa lúc đưa về màn bắt đầu: nạp lại tour và phát tiếp (không điều hướng lần nữa)
|