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.
Files changed (3) hide show
  1. package/README.md +35 -2
  2. package/package.json +1 -1
  3. 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.0/tourfp.js"></script>
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.0/tourfp.js' }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tourfp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Ghi và phát tour hướng dẫn trên giao diện web bằng dấu vân tay phần tử, không cần sửa mã nguồn của app.",
5
5
  "main": "tourfp.js",
6
6
  "browser": "tourfp.js",
package/tourfp.js CHANGED
@@ -1,4 +1,4 @@
1
- /* TourFP 0.3.0 — bản thử: ghi tour theo thao tác và khớp phần tử bằng "dấu vân tay".
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
- // tách chữ của phần tử thành các mẩu, bỏ chữ số
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
- if (/\d/.test(v)) digits = true;
84
- for (const seg of v.split(/\d[\d.,%\/:-]*/)) { const s = norm(seg); if (s.length >= 2 && hasLetters(s)) { out.push(s.slice(0, 60)); num.push(/\d/.test(v)); } }
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 (!p) return '';
110
- return p.getAttribute('href') || p.getAttribute('xlink:href') || 'd:' + hash(p.getAttribute('d') || '');
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) e = p;
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
- // các phần tử khác trên màn có chữ/icon/thuộc tính y hệt đích (không tính phần tử bọc ngoài hay nằm trong đích)
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: 2, tag: tagOf(el), role: el.getAttribute('role') || '', label: '', segs: [], mixed: false, dyn: 0, cover: 1, key: {}, attr: {}, icon: iconOf(el), comp: compChain(el), anchor: '', anchorSkip: 0, pos: posOf(el), hint: '', ctx: context() };
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 => !isApiValue(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 && p.all.length) {
237
- const stat = p.all.filter(s => !isApiValue(s));
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
- fp.label = fp.segs.join(' ').slice(0, 60);
241
- fp.dyn = p.all.length - stat.length; // số mẩu chữ bị loại vì là giá trị API
242
- fp.mixed = fp.dyn > 0 || p.digits || big;
243
- fp.cover = +(lettersLen(fp.segs.join('')) / lettersLen(norm(el.textContent))).toFixed(2);
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
- for (const k of KEY) { const v = el.getAttribute(k); if (v && !/\d{3,}/.test(v)) fp.key[k] = v; }
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 có chữ/icon/thuộc tính y hệt (ví dụ ba nút bút chì): nếu có thì khối đứng trước là bắt buộc
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
- if (hasStrong(fp)) {
251
- const group = twinsOf(fp, el);
252
- fp.twins = group.length;
253
- if (group.length) {
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 = group.find(e => sigOf(e) === sig);
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
- if (byAnchor) byAnchor = !group.some(e => sim(fp.anchor, anchorCands(e)[fp.anchorSkip] || ''));
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.filter(e => sigOf(e) === sig && (e.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)).length;
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.label) add(W.label, labelScore(fp, e));
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
- const hasStrong = fp => !!(fp.label || fp.icon || Object.keys(fp.key).length);
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
- // bỏ qua nếu vừa có cú bấm (trong 1,5 giây, hoặc từ lúc chuột vào phần tử đang rê): khi đó thứ bật ra là do bấm
427
- if (!recording || performance.now() - lastDown < 1500 || lastDown > hoverT || !hoverEl || !hoverEl.isConnected) return;
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
- document.documentElement.append(box, tip, panel);
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.label || fp.icon || fp.key.name || fp.key.formcontrolname || fp.attr.placeholder || fp.tag;
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
- const loop = () => { place(); raf = requestAnimationFrame(loop); };
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
- if (!e.target.closest(POPUP) && hoverEl !== target) { hoverEl = target; hoverT = performance.now(); }
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
- function watchEffect(step) {
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 => { if (recording && e.altKey && !isUi(e.target)) e.stopImmediatePropagation(); else if (!recording && !isUi(e.target)) gate(e, false); };
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
- if (inside && st.kind !== 'info' && st.kind !== 'hover') { if (isClick && st.kind === 'click') { const i = playing; advancing = true; setTimeout(() => { if (playing === i) show(i + 1); }, 300); } return; }
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
- // chờ đến khi màn hình của bước hiện ra và tìm được đích
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.el) return fin(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.el && (!menuOnly || menuOf(r.el))) { hit = a.idx; break; } }
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 && find(nx.fp).el) return show(i + 1);
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 (!r.el) {
659
- rich(tipText, `${head}: không tìm thấy «${name}»` + (r.expected ? `\nCần ở màn: ${r.expected.path} ${r.expected.dialog} ${r.expected.step}` : ''));
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` + (r.status === 'ambiguous' ? ' · ⚠ mơ hồ' : '');
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.el) show(i + 1); }
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 (find(steps[i].fp).el) return i;
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 (find(steps[i].fp).el) return show(i);
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) : { el: null }; // chờ một nhịp phòng khi màn chưa vẽ xong
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.el ? show(n) : endNotice();
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) && find(steps[i].fp).el) start = i; // đang đứng sẵn giữa chặng
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 (!r.el || 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
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
- load: a => { fullSteps = null; steps.length = 0; steps.push(...a); setInfo(); },
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)