dsh-browser-playwright-codex 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/session.d.ts CHANGED
@@ -7,6 +7,7 @@ import type { BrowserContext, CDPSession, Locator, Page, Request } from 'playwri
7
7
  import type { PlaywrightProvider } from './provider.ts';
8
8
  import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, LoadState } from './types.ts';
9
9
  import type { RefSignature } from './page-snapshot.ts';
10
+ import { type CookieRow } from './login-sites.ts';
10
11
  import type { ActionResult, PendingDialogRecord } from './provider.ts';
11
12
  /** Live session over one browser context owned by one caller. */
12
13
  export declare class PlaywrightSession {
@@ -50,6 +51,23 @@ export declare class PlaywrightSession {
50
51
  /** Nonce of the baseline ref space; a change means navigation reset. */
51
52
  lastRefNonce: string | undefined;
52
53
  constructor(provider: PlaywrightProvider, owner: string, context: BrowserContext);
54
+ /**
55
+ * The session's cookies, reduced to what the login inference needs. `value` is dropped here,
56
+ * at the only place cookies enter the summary path, so nothing downstream can leak it.
57
+ */
58
+ cookieJar(): Promise<readonly CookieRow[]>;
59
+ /**
60
+ * Registrable domains of the pages this browser currently has open — the "first-party" set the
61
+ * login inference attributes cookies to. Taken from the live pages rather than the browser's
62
+ * history database: the plugin only claims what it can see, and never reads a new data source.
63
+ */
64
+ /**
65
+ * Page titles by registrable domain for the pages currently open — the site's own name for
66
+ * itself, which is what browsers and password managers show instead of a bare hostname. Titles
67
+ * are display-only and trimmed; a domain with no open page simply has no title.
68
+ */
69
+ siteTitles(): Promise<Map<string, string>>;
70
+ visitedHosts(): string[];
53
71
  /** Run one operation under busy tracking: idle disposal defers until it settles. */
54
72
  run<T>(operation: () => Promise<T>): Promise<T>;
55
73
  /** Fast actionability pre-check (visible/enabled/editable) before an action. */
@@ -87,7 +105,7 @@ export declare class PlaywrightSession {
87
105
  * Answer the pending native dialog and let the action blocked behind it
88
106
  * finish. This is the only place a dialog is ever accepted or dismissed.
89
107
  */
90
- resolveDialog(accept: boolean, promptText: string | undefined, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
108
+ resolveDialog(accept: boolean, promptText: string | undefined, signal?: AbortSignal): Promise<BrowserSnapshot>;
91
109
  /** Read the snapshot tree from one page (must run inside a run()). */
92
110
  capture(page: Page, opts: {
93
111
  interactiveOnly?: boolean;
@@ -135,7 +153,7 @@ export declare class PlaywrightSession {
135
153
  buildRefSignatures(nodes: readonly BrowserNode[]): Map<string, RefSignature>;
136
154
  /** The document nonce shared by this snapshot's refs, or undefined. */
137
155
  refNonce(nodes: readonly BrowserNode[]): string | undefined;
138
- navigate(url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
156
+ navigate(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot | {
139
157
  url: string;
140
158
  title: string;
141
159
  nodes: never[];
@@ -162,13 +180,13 @@ export declare class PlaywrightSession {
162
180
  * an overlaying element can never swallow the click silently. Returns the
163
181
  * first unobstructed {x, y}, or throws describing what intercepted it.
164
182
  */
165
- resolveClickPoint(locator: Locator, signal: AbortSignal | undefined): Promise<{
183
+ resolveClickPoint(locator: Locator, signal?: AbortSignal): Promise<{
166
184
  x: number;
167
185
  y: number;
168
186
  hitDesc: string;
169
187
  hitSelf: boolean;
170
188
  }>;
171
- click(ref: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
189
+ click(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
172
190
  url: string;
173
191
  title: string;
174
192
  nodes: never[];
@@ -178,12 +196,12 @@ export declare class PlaywrightSession {
178
196
  }>;
179
197
  /**
180
198
  * Click an element the snapshot gave no ref, addressed by its visible label
181
- * (e.g. a menu item revealed by browser_hover that is plain text). The
199
+ * (e.g. a menu item revealed by browser_click with mode "hover" that is plain text). The
182
200
  * label must equal an element's own direct text; the deepest match wins,
183
201
  * and the landing note names what was actually clicked plus how many
184
202
  * elements shared the label.
185
203
  */
186
- clickText(text: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
204
+ clickText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
187
205
  url: string;
188
206
  title: string;
189
207
  nodes: never[];
@@ -230,7 +248,7 @@ export declare class PlaywrightSession {
230
248
  truncated: boolean;
231
249
  dialogNote: string;
232
250
  }>;
233
- clickAt(x: number, y: number, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
251
+ clickAt(x: number, y: number, signal?: AbortSignal): Promise<BrowserSnapshot | {
234
252
  url: string;
235
253
  title: string;
236
254
  nodes: never[];
@@ -238,7 +256,7 @@ export declare class PlaywrightSession {
238
256
  truncated: boolean;
239
257
  dialogNote: string;
240
258
  }>;
241
- clickAtRef(ref: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
259
+ clickAtRef(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
242
260
  url: string;
243
261
  title: string;
244
262
  nodes: never[];
@@ -246,10 +264,10 @@ export declare class PlaywrightSession {
246
264
  truncated: boolean;
247
265
  dialogNote: string;
248
266
  }>;
249
- fill(ref: string, text: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
267
+ fill(ref: string, text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
250
268
  /** Read the field back and decide whether the fill landed. */
251
269
  verifyFill(locator: Locator, tag: string, text: string): Promise<boolean>;
252
- press(ref: string, key: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
270
+ press(ref: string, key: string, signal?: AbortSignal): Promise<BrowserSnapshot | {
253
271
  url: string;
254
272
  title: string;
255
273
  nodes: never[];
@@ -265,14 +283,14 @@ export declare class PlaywrightSession {
265
283
  * moves is the only cheap proof that something appeared, so the returned
266
284
  * snapshot carries a note comparing them.
267
285
  */
268
- hover(ref: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
286
+ hover(ref: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
269
287
  /**
270
288
  * Hover a trigger the snapshot gave no ref, addressed by its visible label
271
289
  * (e.g. a plain span that reveals a menu on mouseenter). The label must
272
290
  * equal an element's own direct text; the deepest match wins, and the
273
291
  * landing note names what was hovered plus how many elements shared it.
274
292
  */
275
- hoverText(text: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
293
+ hoverText(text: string, signal?: AbortSignal): Promise<BrowserSnapshot>;
276
294
  /**
277
295
  * Shared hover core: rest the pointer on a resolved locator, then report
278
296
  * what the actionable ref count did. The label names the target in the
@@ -280,17 +298,17 @@ export declare class PlaywrightSession {
280
298
  * target came from a ref).
281
299
  */
282
300
  hoverLocator(locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<BrowserSnapshot>;
283
- scroll(direction: 'up' | 'down', amount: number, ref: string, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
284
- back(signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
285
- forward(signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
286
- wait(ms: number, signal: AbortSignal | undefined): Promise<void>;
301
+ scroll(direction: 'up' | 'down', amount: number, ref: string | undefined, signal?: AbortSignal): Promise<BrowserSnapshot>;
302
+ back(signal?: AbortSignal): Promise<BrowserSnapshot>;
303
+ forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
304
+ wait(ms: number, signal?: AbortSignal): Promise<void>;
287
305
  tabs(): Promise<{
288
306
  index: number;
289
307
  url: string;
290
308
  title: string;
291
309
  active: boolean;
292
310
  }[]>;
293
- switchTab(index: number, signal: AbortSignal | undefined): Promise<BrowserSnapshot | {
311
+ switchTab(index: number, signal?: AbortSignal): Promise<BrowserSnapshot | {
294
312
  url: string;
295
313
  title: string;
296
314
  nodes: never[];
@@ -298,8 +316,8 @@ export declare class PlaywrightSession {
298
316
  truncated: boolean;
299
317
  dialogNote: string;
300
318
  }>;
301
- openTab(url: string, waitUntil: LoadState, signal: AbortSignal | undefined): Promise<BrowserSnapshot>;
302
- closeTab(index: number, signal: AbortSignal | undefined): Promise<void>;
319
+ openTab(url: string, waitUntil: LoadState, signal?: AbortSignal): Promise<BrowserSnapshot>;
320
+ closeTab(index: number, signal?: AbortSignal): Promise<void>;
303
321
  /**
304
322
  * Screenshots get their own floor instead of reusing the navigation budget: a
305
323
  * full-page capture is sized by the page and encoded by the CPU, so a loaded machine
@@ -311,12 +329,12 @@ export declare class PlaywrightSession {
311
329
  screenshot(opts: {
312
330
  fullPage?: boolean;
313
331
  ref?: string;
314
- } | undefined, signal: AbortSignal | undefined): Promise<{
332
+ } | undefined, signal?: AbortSignal): Promise<{
315
333
  mime: "image/png";
316
334
  bytes: Buffer<ArrayBufferLike>;
317
335
  }>;
318
336
  pageData(): Promise<unknown>;
319
- evaluate(expression: string, signal: AbortSignal | undefined): Promise<unknown>;
337
+ evaluate(expression: string, signal?: AbortSignal): Promise<unknown>;
320
338
  /**
321
339
  * Send one raw CDP command on the current page's session and return its
322
340
  * result. This is the "direct protocol" escape hatch behind every other
@@ -325,7 +343,7 @@ export declare class PlaywrightSession {
325
343
  * are allowed) lives in the tool layer via lib/cdp-policy.js; this method
326
344
  * itself performs no filtering so internal callers keep full control.
327
345
  */
328
- cdp(method: string, params: Record<string, unknown>, signal: AbortSignal | undefined): Promise<unknown>;
346
+ cdp(method: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
329
347
  close(): Promise<void>;
330
348
  timeoutMs(): number;
331
349
  ensurePage(): Promise<Page>;
package/lib/session.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { looksLikeToken, registrableDomain } from "./login-sites.js";
1
2
  import { BrowserError } from "./errors.js";
2
3
  import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "./page-diagnostics.js";
3
4
  import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
@@ -45,6 +46,69 @@ export class PlaywrightSession {
45
46
  this.owner = owner;
46
47
  this.context = context;
47
48
  }
49
+ /**
50
+ * The session's cookies, reduced to what the login inference needs. `value` is dropped here,
51
+ * at the only place cookies enter the summary path, so nothing downstream can leak it.
52
+ */
53
+ async cookieJar() {
54
+ const raw = await this.context.cookies();
55
+ return raw.map((cookie) => ({
56
+ name: cookie.name,
57
+ domain: cookie.domain,
58
+ ...(typeof cookie.expires === 'number' ? { expires: cookie.expires } : {}),
59
+ ...(cookie.httpOnly === true ? { httpOnly: true } : {}),
60
+ ...(cookie.secure === true ? { secure: true } : {}),
61
+ // The value is inspected here and reduced to one boolean; it never leaves this function.
62
+ ...(looksLikeToken(cookie.value) ? { tokenLike: true } : {}),
63
+ }));
64
+ }
65
+ /**
66
+ * Registrable domains of the pages this browser currently has open — the "first-party" set the
67
+ * login inference attributes cookies to. Taken from the live pages rather than the browser's
68
+ * history database: the plugin only claims what it can see, and never reads a new data source.
69
+ */
70
+ /**
71
+ * Page titles by registrable domain for the pages currently open — the site's own name for
72
+ * itself, which is what browsers and password managers show instead of a bare hostname. Titles
73
+ * are display-only and trimmed; a domain with no open page simply has no title.
74
+ */
75
+ async siteTitles() {
76
+ const titles = new Map();
77
+ for (const page of this.context.pages()) {
78
+ try {
79
+ const url = page.url();
80
+ if (!url.startsWith('http://') && !url.startsWith('https://'))
81
+ continue;
82
+ const domain = registrableDomain(new URL(url).host);
83
+ if (domain === '' || titles.has(domain))
84
+ continue;
85
+ const title = (await page.title()).replace(/\s+/g, ' ').trim();
86
+ if (title !== '')
87
+ titles.set(domain, title.slice(0, 80));
88
+ }
89
+ catch {
90
+ /* a page can be mid-navigation or already closed */
91
+ }
92
+ }
93
+ return titles;
94
+ }
95
+ visitedHosts() {
96
+ const hosts = new Set();
97
+ for (const page of this.context.pages()) {
98
+ try {
99
+ const url = page.url();
100
+ if (!url.startsWith('http://') && !url.startsWith('https://'))
101
+ continue;
102
+ const domain = registrableDomain(new URL(url).host);
103
+ if (domain !== '')
104
+ hosts.add(domain);
105
+ }
106
+ catch {
107
+ /* a page can be mid-navigation or already closed */
108
+ }
109
+ }
110
+ return [...hosts];
111
+ }
48
112
  /** Run one operation under busy tracking: idle disposal defers until it settles. */
49
113
  run(operation) {
50
114
  this.provider.beginOp(this.owner);
@@ -223,7 +287,7 @@ export class PlaywrightSession {
223
287
  }
224
288
  /**
225
289
  * Click an element the snapshot gave no ref, addressed by its visible label
226
- * (e.g. a menu item revealed by browser_hover that is plain text). The
290
+ * (e.g. a menu item revealed by browser_click with mode "hover" that is plain text). The
227
291
  * label must equal an element's own direct text; the deepest match wins,
228
292
  * and the landing note names what was actually clicked plus how many
229
293
  * elements shared the label.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 域名对照表(升级版):每个域名一条「说明 + 归属公司」,卡片按公司折叠成一行。
3
+ *
4
+ * 用户的原话:"这种一个类型的肯定要放一起啊" —— 京东系二十来个域各自占一行,小半屏都是它,
5
+ * 既看不出重点、也找不到别的站。所以这份表不只回答"这是什么",还回答"它属于谁"。
6
+ *
7
+ * 两条来源,按可靠性排序:
8
+ * 1. **网页自己的标题**(浏览器/密码管理器的做法):网站自己报的名字,不出网、不会过期;
9
+ * 由 provider 提供,只在那个域当前开着页面时才有。
10
+ * 2. **这份表**:离线、可测、覆盖常见的国内服务与大厂;**会被时代淘汰**,所以只在标题缺失时兜底,
11
+ * 而且**宁可留空也不瞎猜**(认不出的就显示域名本身,也没有归属)。
12
+ *
13
+ * 附带的好处:广告/统计类域名在表里被标出来,而且**整族归到"广告/统计平台"一组** —— 它们成片出现在
14
+ * "像登录态"里,本身就是判据被数据相似性骗了的信号。
15
+ * @module dsh-browser-playwright-codex/site-aliases
16
+ */
17
+ /**
18
+ * A short Chinese explanation of what a domain is, or undefined when we do not know.
19
+ * @param domain - registrable domain, e.g. `jinritemai.com`.
20
+ * @returns the explanation, or undefined (never a guess).
21
+ */
22
+ export declare function siteAlias(domain: string): string | undefined;
23
+ /**
24
+ * Which company a domain belongs to, for collapsing sibling domains into one row.
25
+ * @param domain - registrable domain.
26
+ * @returns the group key and its 中文 label, or undefined when the domain is not part of a group.
27
+ */
28
+ export declare function siteGroup(domain: string): {
29
+ key: string;
30
+ label: string;
31
+ } | undefined;
32
+ /** How many domains this table explains — tests and docs use it to stay honest about coverage. */
33
+ export declare const ALIAS_COUNT: number;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 域名对照表(升级版):每个域名一条「说明 + 归属公司」,卡片按公司折叠成一行。
3
+ *
4
+ * 用户的原话:"这种一个类型的肯定要放一起啊" —— 京东系二十来个域各自占一行,小半屏都是它,
5
+ * 既看不出重点、也找不到别的站。所以这份表不只回答"这是什么",还回答"它属于谁"。
6
+ *
7
+ * 两条来源,按可靠性排序:
8
+ * 1. **网页自己的标题**(浏览器/密码管理器的做法):网站自己报的名字,不出网、不会过期;
9
+ * 由 provider 提供,只在那个域当前开着页面时才有。
10
+ * 2. **这份表**:离线、可测、覆盖常见的国内服务与大厂;**会被时代淘汰**,所以只在标题缺失时兜底,
11
+ * 而且**宁可留空也不瞎猜**(认不出的就显示域名本身,也没有归属)。
12
+ *
13
+ * 附带的好处:广告/统计类域名在表里被标出来,而且**整族归到"广告/统计平台"一组** —— 它们成片出现在
14
+ * "像登录态"里,本身就是判据被数据相似性骗了的信号。
15
+ * @module dsh-browser-playwright-codex/site-aliases
16
+ */
17
+ /** Group key → 折叠行显示的中文名. */
18
+ const GROUP_LABELS = {
19
+ jd: '京东系',
20
+ alibaba: '阿里系',
21
+ bytedance: '字节系',
22
+ microsoft: '微软系',
23
+ meta: 'Meta 系',
24
+ google: 'Google 系',
25
+ ads: '广告 / 统计平台',
26
+ tencent: '腾讯系',
27
+ netease: '网易系',
28
+ };
29
+ /** Domain → what it is and who owns it. Keys are registrable domains. */
30
+ const SITES = {
31
+ // ── 京东系(真实名单里占了二十来个域,所以单独成组)──────────────────────────
32
+ 'jd.com': { name: '京东', group: 'jd' },
33
+ 'jingdong.com': { name: '京东(旧域名)', group: 'jd' },
34
+ 'jd.hk': { name: '京东香港', group: 'jd' },
35
+ 'jddj.com': { name: '京东到家', group: 'jd' },
36
+ 'jdpay.com': { name: '京东支付', group: 'jd' },
37
+ 'jcloud.com': { name: '京东云', group: 'jd' },
38
+ 'jdcloud.com': { name: '京东云', group: 'jd' },
39
+ 'jdbusiness.com': { name: '京东企业购', group: 'jd' },
40
+ 'jdindustry.com': { name: '京东工业', group: 'jd' },
41
+ 'jdl.cn': { name: '京东物流', group: 'jd' },
42
+ 'jdl.com': { name: '京东物流', group: 'jd' },
43
+ 'jdwl.com': { name: '京东物流', group: 'jd' },
44
+ 'healthjd.com': { name: '京东健康', group: 'jd' },
45
+ 'jingdonghealth.cn': { name: '京东健康', group: 'jd' },
46
+ '7fresh.com': { name: '七鲜(京东生鲜超市)', group: 'jd' },
47
+ 'baitiao.com': { name: '京东白条', group: 'jd' },
48
+ 'jingxi.com': { name: '京喜', group: 'jd' },
49
+ 'five-star.cn': { name: '京东五星电器', group: 'jd' },
50
+ // ── 阿里系 ──────────────────────────────────────────────────────────────────
51
+ 'taobao.com': { name: '淘宝', group: 'alibaba' },
52
+ 'tmall.com': { name: '天猫', group: 'alibaba' },
53
+ 'goofish.com': { name: '闲鱼(二手交易)', group: 'alibaba' },
54
+ 'qianwen.com': { name: '通义千问(阿里的 AI 助手)', group: 'alibaba' },
55
+ 'qianwenai.com': { name: '通义千问(阿里的 AI 助手)', group: 'alibaba' },
56
+ 'a-mo.net': { name: '阿里妈妈(广告投放域名)', group: 'alibaba' },
57
+ 'a-mx.com': { name: '阿里妈妈(广告投放域名)', group: 'alibaba' },
58
+ 'yangkeduo.com': { name: '拼多多', group: 'alibaba' },
59
+ // ── 字节系 ──────────────────────────────────────────────────────────────────
60
+ 'douyin.com': { name: '抖音', group: 'bytedance' },
61
+ 'doubao.com': { name: '豆包(字节的 AI 助手)', group: 'bytedance' },
62
+ 'toutiao.com': { name: '今日头条', group: 'bytedance' },
63
+ 'volcengine.com': { name: '火山引擎(字节云服务)', group: 'bytedance' },
64
+ 'jinritemai.com': { name: '巨量千川(抖音电商广告后台)', group: 'bytedance' },
65
+ 'feishu.cn': { name: '飞书', group: 'bytedance' },
66
+ 'larksuite.com': { name: '飞书(国际版)', group: 'bytedance' },
67
+ // ── 其他国内 ────────────────────────────────────────────────────────────────
68
+ 'chaoxing.com': { name: '超星学习通(网课平台)' },
69
+ 'juejin.cn': { name: '掘金(开发者社区)' },
70
+ 'baidu.com': { name: '百度' },
71
+ 'kuaishou.com': { name: '快手' },
72
+ 'deepseek.com': { name: 'DeepSeek(深度求索)' },
73
+ // ── 微软系 ──────────────────────────────────────────────────────────────────
74
+ 'live.com': { name: '微软账号(Outlook 等)', group: 'microsoft' },
75
+ 'microsoftonline.com': { name: '微软企业账号登录', group: 'microsoft' },
76
+ 'bing.com': { name: '必应(微软搜索)', group: 'microsoft' },
77
+ 'clarity.ms': { name: 'Microsoft Clarity(行为统计)', group: 'microsoft' },
78
+ // ── Meta 系 ─────────────────────────────────────────────────────────────────
79
+ 'meta.com': { name: 'Meta(Facebook 母公司)', group: 'meta' },
80
+ 'facebook.com': { name: 'Facebook', group: 'meta' },
81
+ // ── Google 系 ───────────────────────────────────────────────────────────────
82
+ 'doubleclick.net': { name: 'Google 广告', group: 'google' },
83
+ 'google-analytics.com': { name: 'Google Analytics(统计)', group: 'google' },
84
+ 'googletagmanager.com': { name: 'Google Tag Manager(统计)', group: 'google' },
85
+ // ── 其他国外服务 ────────────────────────────────────────────────────────────
86
+ 'github.com': { name: 'GitHub(代码托管)' },
87
+ 'openrouter.ai': { name: 'OpenRouter(AI 模型聚合服务)' },
88
+ 'fearlessrevolution.com': { name: 'FearlessRevolution(游戏修改器社区)' },
89
+ // ── 广告 / 统计平台(成片出现时就是"判据被骗了"的信号)────────────────────────
90
+ 'adroll.com': { name: 'AdRoll(广告再营销)', group: 'ads' },
91
+ 'pubmatic.com': { name: 'PubMatic(广告交易平台)', group: 'ads' },
92
+ 'criteo.com': { name: 'Criteo(广告再营销)', group: 'ads' },
93
+ 'adnxs.com': { name: 'Xandr/AppNexus(广告交易平台)', group: 'ads' },
94
+ 'rubiconproject.com': { name: 'Magnite(广告交易平台)', group: 'ads' },
95
+ 'casalemedia.com': { name: 'Index Exchange(广告交易平台)', group: 'ads' },
96
+ 'scorecardresearch.com': { name: 'Comscore(受众统计)', group: 'ads' },
97
+ 'adsrvr.org': { name: 'The Trade Desk(广告投放)', group: 'ads' },
98
+ 'taboola.com': { name: 'Taboola(内容推荐广告)', group: 'ads' },
99
+ 'outbrain.com': { name: 'Outbrain(内容推荐广告)', group: 'ads' },
100
+ 'mmstat.com': { name: '阿里统计', group: 'ads' },
101
+ 'umeng.com': { name: '友盟(移动统计)', group: 'ads' },
102
+ 'umengcloud.com': { name: '友盟(移动统计)', group: 'ads' },
103
+ 'cnzz.com': { name: 'CNZZ 站长统计', group: 'ads' },
104
+ 'hotjar.com': { name: 'Hotjar(行为统计)', group: 'ads' },
105
+ 'sentry.io': { name: 'Sentry(错误监控)', group: 'ads' },
106
+ sajssdk: { name: '神策数据(统计)', group: 'ads' },
107
+ sensorsdata: { name: '神策数据(统计)', group: 'ads' },
108
+ };
109
+ /**
110
+ * A short Chinese explanation of what a domain is, or undefined when we do not know.
111
+ * @param domain - registrable domain, e.g. `jinritemai.com`.
112
+ * @returns the explanation, or undefined (never a guess).
113
+ */
114
+ export function siteAlias(domain) {
115
+ return SITES[normalize(domain)]?.name;
116
+ }
117
+ /**
118
+ * Which company a domain belongs to, for collapsing sibling domains into one row.
119
+ * @param domain - registrable domain.
120
+ * @returns the group key and its 中文 label, or undefined when the domain is not part of a group.
121
+ */
122
+ export function siteGroup(domain) {
123
+ const key = SITES[normalize(domain)]?.group;
124
+ if (key === undefined)
125
+ return undefined;
126
+ return { key, label: GROUP_LABELS[key] ?? key };
127
+ }
128
+ /** How many domains this table explains — tests and docs use it to stay honest about coverage. */
129
+ export const ALIAS_COUNT = Object.keys(SITES).length;
130
+ function normalize(domain) {
131
+ return domain.trim().replace(/^\./, '').toLowerCase();
132
+ }