feat(tracker): 3.4.0 - iframe, resourceBaseHref & finder performance options
This commit is contained in:
parent
43862e32e1
commit
ba7ae009c4
12 changed files with 228 additions and 430 deletions
2
tracker/tracker/package-lock.json
generated
2
tracker/tracker/package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"version": "3.2.5",
|
||||
"version": "3.3.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.0",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { timestamp, log } from '../utils';
|
||||
import { timestamp, log, warn } from '../utils';
|
||||
import { Timestamp, TechnicalInfo, PageClose } from '../../messages';
|
||||
import Message from '../../messages/message';
|
||||
import Nodes from './nodes';
|
||||
|
|
@ -24,6 +24,8 @@ export type Options = {
|
|||
session_pageno_key: string;
|
||||
local_uuid_key: string;
|
||||
ingestPoint: string;
|
||||
resourceBaseHref: string, // resourceHref?
|
||||
//resourceURLRewriter: (url: string) => string | boolean,
|
||||
__is_snippet: boolean;
|
||||
__debug_report_edp: string | null;
|
||||
onStart?: (info: OnStartInfo) => void;
|
||||
|
|
@ -65,10 +67,12 @@ export default class App {
|
|||
session_pageno_key: '__openreplay_pageno',
|
||||
local_uuid_key: '__openreplay_uuid',
|
||||
ingestPoint: DEFAULT_INGEST_POINT,
|
||||
resourceBaseHref: '',
|
||||
__is_snippet: false,
|
||||
__debug_report_edp: null,
|
||||
obscureTextEmails: true,
|
||||
obscureTextNumbers: false,
|
||||
captureIFrames: false,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
|
@ -118,6 +122,7 @@ export default class App {
|
|||
if(this.options.__debug_report_edp !== null) {
|
||||
fetch(this.options.__debug_report_edp, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
context,
|
||||
error: `${e}`
|
||||
|
|
@ -199,11 +204,26 @@ export default class App {
|
|||
return this._sessionID || undefined;
|
||||
}
|
||||
getHost(): string {
|
||||
return new URL(this.options.ingestPoint).host;
|
||||
return new URL(this.options.ingestPoint).hostname
|
||||
}
|
||||
getProjectKey(): string {
|
||||
return this.projectKey
|
||||
}
|
||||
getBaseHref(): string {
|
||||
if (this.options.resourceBaseHref) {
|
||||
return this.options.resourceBaseHref
|
||||
}
|
||||
if (document.baseURI) {
|
||||
return document.baseURI
|
||||
}
|
||||
// IE only
|
||||
return document.head
|
||||
?.getElementsByTagName("base")[0]
|
||||
?.getAttribute("href") || location.origin + location.pathname
|
||||
}
|
||||
|
||||
isServiceURL(url: string): boolean {
|
||||
return url.startsWith(this.options.ingestPoint);
|
||||
return url.startsWith(this.options.ingestPoint)
|
||||
}
|
||||
|
||||
active(): boolean {
|
||||
|
|
@ -211,10 +231,10 @@ export default class App {
|
|||
}
|
||||
private _start(reset: boolean): Promise<OnStartInfo> {
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
if (!this.worker) {
|
||||
throw new Error("Stranger things: no worker found");
|
||||
return Promise.reject("No worker found: perhaps, CSP is not set.");
|
||||
}
|
||||
this.isActive = true;
|
||||
|
||||
let pageNo: number = 0;
|
||||
const pageNoStr = sessionStorage.getItem(this.options.session_pageno_key);
|
||||
|
|
@ -273,7 +293,7 @@ export default class App {
|
|||
this._sessionID = sessionID;
|
||||
}
|
||||
if (!this.worker) {
|
||||
throw new Error("Stranger things: no worker found after start request");
|
||||
throw new Error("no worker found after start request (this might not happen)");
|
||||
}
|
||||
this.worker.postMessage({ token, beaconSizeLimit });
|
||||
this.startCallbacks.forEach((cb) => cb());
|
||||
|
|
@ -289,6 +309,7 @@ export default class App {
|
|||
})
|
||||
.catch(e => {
|
||||
this.stop();
|
||||
warn("OpenReplay was unable to start. ", e)
|
||||
this.sendDebugReport("session_start", e);
|
||||
throw e;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { stars, hasOpenreplayAttribute, getBaseURI } from '../utils';
|
||||
import { stars, hasOpenreplayAttribute } from '../utils';
|
||||
import {
|
||||
CreateDocument,
|
||||
CreateElementNode,
|
||||
|
|
@ -10,37 +10,49 @@ import {
|
|||
RemoveNodeAttribute,
|
||||
MoveNode,
|
||||
RemoveNode,
|
||||
CreateIFrameDocument,
|
||||
} from '../../messages';
|
||||
import App from './index';
|
||||
|
||||
interface Window extends WindowProxy {
|
||||
HTMLInputElement: typeof HTMLInputElement,
|
||||
HTMLLinkElement: typeof HTMLLinkElement,
|
||||
HTMLStyleElement: typeof HTMLStyleElement,
|
||||
SVGStyleElement: typeof SVGStyleElement,
|
||||
HTMLIFrameElement: typeof HTMLIFrameElement,
|
||||
Text: typeof Text,
|
||||
Element: typeof Element,
|
||||
//parent: Window,
|
||||
}
|
||||
|
||||
|
||||
type WindowConstructor =
|
||||
Document |
|
||||
Element |
|
||||
Text |
|
||||
HTMLInputElement |
|
||||
HTMLLinkElement |
|
||||
HTMLStyleElement |
|
||||
HTMLIFrameElement
|
||||
|
||||
// type ConstructorNames =
|
||||
// 'Element' |
|
||||
// 'Text' |
|
||||
// 'HTMLInputElement' |
|
||||
// 'HTMLLinkElement' |
|
||||
// 'HTMLStyleElement' |
|
||||
// 'HTMLIFrameElement'
|
||||
type Constructor<T> = { new (...args: any[]): T , name: string };
|
||||
|
||||
|
||||
function isSVGElement(node: Element): node is SVGElement {
|
||||
return node.namespaceURI === 'http://www.w3.org/2000/svg';
|
||||
}
|
||||
function isIgnored(node: Node): boolean {
|
||||
if (node instanceof Text) {
|
||||
return false;
|
||||
}
|
||||
if (!(node instanceof Element)) {
|
||||
return true;
|
||||
}
|
||||
const tag = node.tagName.toUpperCase();
|
||||
if (tag === 'LINK') {
|
||||
const rel = node.getAttribute('rel');
|
||||
const as = node.getAttribute('as');
|
||||
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
|
||||
}
|
||||
return (
|
||||
tag === 'SCRIPT' ||
|
||||
tag === 'NOSCRIPT' ||
|
||||
tag === 'META' ||
|
||||
tag === 'TITLE' ||
|
||||
tag === 'BASE'
|
||||
);
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
obscureTextEmails: boolean;
|
||||
obscureTextNumbers: boolean;
|
||||
captureIFrames: boolean;
|
||||
}
|
||||
|
||||
export default class Observer {
|
||||
|
|
@ -51,17 +63,33 @@ export default class Observer {
|
|||
private readonly attributesList: Array<Set<string> | undefined>;
|
||||
private readonly textSet: Set<number>;
|
||||
private readonly textMasked: Set<number>;
|
||||
private readonly options: Options;
|
||||
constructor(private readonly app: App, opts: Options) {
|
||||
this.options = opts;
|
||||
constructor(private readonly app: App, private readonly options: Options, private readonly context: Window = window) {
|
||||
this.observer = new MutationObserver(
|
||||
this.app.safe((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const target = mutation.target;
|
||||
if (isIgnored(target) || !document.contains(target)) {
|
||||
const type = mutation.type;
|
||||
|
||||
// Special case
|
||||
// Document 'childList' might happen in case of iframe.
|
||||
// TODO: generalize as much as possible
|
||||
if (this.isInstance(target, Document)
|
||||
&& type === 'childList'
|
||||
//&& new Array(mutation.addedNodes).some(node => this.isInstance(node, HTMLHtmlElement))
|
||||
) {
|
||||
const parentFrame = target.defaultView?.frameElement
|
||||
if (!parentFrame) { continue }
|
||||
this.bindTree(target.documentElement)
|
||||
const frameID = this.app.nodes.getID(parentFrame)
|
||||
const docID = this.app.nodes.getID(target.documentElement)
|
||||
if (frameID === undefined || docID === undefined) { continue }
|
||||
this.app.send(CreateIFrameDocument(frameID, docID));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isIgnored(target) || !context.document.contains(target)) {
|
||||
continue;
|
||||
}
|
||||
const type = mutation.type;
|
||||
if (type === 'childList') {
|
||||
for (let i = 0; i < mutation.removedNodes.length; i++) {
|
||||
this.bindTree(mutation.removedNodes[i]);
|
||||
|
|
@ -114,6 +142,43 @@ export default class Observer {
|
|||
this.textMasked.clear();
|
||||
}
|
||||
|
||||
// TODO: we need a type expert here so we won't have to ignore the lines
|
||||
private isInstance<T extends WindowConstructor>(node: Node, constr: Constructor<T>): node is T {
|
||||
let context = this.context;
|
||||
while(context.parent && context.parent !== context) {
|
||||
// @ts-ignore
|
||||
if (node instanceof context[constr.name]) {
|
||||
return true
|
||||
}
|
||||
// @ts-ignore
|
||||
context = context.parent
|
||||
}
|
||||
// @ts-ignore
|
||||
return node instanceof context[constr.name]
|
||||
}
|
||||
|
||||
private isIgnored(node: Node): boolean {
|
||||
if (this.isInstance(node, Text)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.isInstance(node, Element)) {
|
||||
return true;
|
||||
}
|
||||
const tag = node.tagName.toUpperCase();
|
||||
if (tag === 'LINK') {
|
||||
const rel = node.getAttribute('rel');
|
||||
const as = node.getAttribute('as');
|
||||
return !(rel?.includes('stylesheet') || as === "style" || as === "font");
|
||||
}
|
||||
return (
|
||||
tag === 'SCRIPT' ||
|
||||
tag === 'NOSCRIPT' ||
|
||||
tag === 'META' ||
|
||||
tag === 'TITLE' ||
|
||||
tag === 'BASE'
|
||||
);
|
||||
}
|
||||
|
||||
private sendNodeAttribute(
|
||||
id: number,
|
||||
node: Element,
|
||||
|
|
@ -130,7 +195,7 @@ export default class Observer {
|
|||
if (value.length > 1e5) {
|
||||
value = '';
|
||||
}
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, getBaseURI()));
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
||||
} else {
|
||||
this.app.send(new SetNodeAttribute(id, name, value));
|
||||
}
|
||||
|
|
@ -148,7 +213,7 @@ export default class Observer {
|
|||
}
|
||||
if (
|
||||
name === 'value' &&
|
||||
node instanceof HTMLInputElement &&
|
||||
this.isInstance(node, HTMLInputElement) &&
|
||||
node.type !== 'button' &&
|
||||
node.type !== 'reset' &&
|
||||
node.type !== 'submit'
|
||||
|
|
@ -159,8 +224,8 @@ export default class Observer {
|
|||
this.app.send(new RemoveNodeAttribute(id, name));
|
||||
return;
|
||||
}
|
||||
if (name === 'style' || name === 'href' && node instanceof HTMLLinkElement) {
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, getBaseURI()));
|
||||
if (name === 'style' || name === 'href' && this.isInstance(node, HTMLLinkElement)) {
|
||||
this.app.send(new SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()));
|
||||
return;
|
||||
}
|
||||
if (name === 'href' || value.length > 1e5) {
|
||||
|
|
@ -170,8 +235,8 @@ export default class Observer {
|
|||
}
|
||||
|
||||
private sendNodeData(id: number, parentElement: Element, data: string): void {
|
||||
if (parentElement instanceof HTMLStyleElement || parentElement instanceof SVGStyleElement) {
|
||||
this.app.send(new SetCSSDataURLBased(id, data, getBaseURI()));
|
||||
if (this.isInstance(parentElement, HTMLStyleElement) || this.isInstance(parentElement, SVGStyleElement)) {
|
||||
this.app.send(new SetCSSDataURLBased(id, data, this.app.getBaseHref()));
|
||||
return;
|
||||
}
|
||||
if (this.textMasked.has(id)) {
|
||||
|
|
@ -201,7 +266,7 @@ export default class Observer {
|
|||
}
|
||||
|
||||
private bindTree(node: Node): void {
|
||||
if (isIgnored(node)) {
|
||||
if (this.isIgnored(node)) {
|
||||
return;
|
||||
}
|
||||
this.bindNode(node);
|
||||
|
|
@ -210,7 +275,7 @@ export default class Observer {
|
|||
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) =>
|
||||
isIgnored(node) || this.app.nodes.getID(node) !== undefined
|
||||
this.isIgnored(node) || this.app.nodes.getID(node) !== undefined
|
||||
? NodeFilter.FILTER_REJECT
|
||||
: NodeFilter.FILTER_ACCEPT,
|
||||
},
|
||||
|
|
@ -231,7 +296,9 @@ export default class Observer {
|
|||
private _commitNode(id: number, node: Node): boolean {
|
||||
const parent = node.parentNode;
|
||||
let parentID: number | undefined;
|
||||
if (id !== 0) {
|
||||
if (this.isInstance(node, HTMLHtmlElement)) {
|
||||
this.indexes[id] = 0
|
||||
} else {
|
||||
if (parent === null) {
|
||||
this.unbindNode(node);
|
||||
return false;
|
||||
|
|
@ -247,7 +314,7 @@ export default class Observer {
|
|||
}
|
||||
if (
|
||||
this.textMasked.has(parentID) ||
|
||||
(node instanceof Element && hasOpenreplayAttribute(node, 'masked'))
|
||||
(this.isInstance(node, Element) && hasOpenreplayAttribute(node, 'masked'))
|
||||
) {
|
||||
this.textMasked.add(id);
|
||||
}
|
||||
|
|
@ -271,7 +338,7 @@ export default class Observer {
|
|||
throw 'commitNode: missing node index';
|
||||
}
|
||||
if (isNew === true) {
|
||||
if (node instanceof Element) {
|
||||
if (this.isInstance(node, Element)) {
|
||||
if (parentID !== undefined) {
|
||||
this.app.send(new
|
||||
CreateElementNode(
|
||||
|
|
@ -287,7 +354,12 @@ export default class Observer {
|
|||
const attr = node.attributes[i];
|
||||
this.sendNodeAttribute(id, node, attr.nodeName, attr.value);
|
||||
}
|
||||
} else if (node instanceof Text) {
|
||||
|
||||
if (this.isInstance(node, HTMLIFrameElement) &&
|
||||
(this.options.captureIFrames || node.getAttribute("data-openreplay-capture"))) {
|
||||
this.handleIframe(node);
|
||||
}
|
||||
} else if (this.isInstance(node, Text)) {
|
||||
// for text node id != 0, hence parentID !== undefined and parent is Element
|
||||
this.app.send(new CreateTextNode(id, parentID as number, index));
|
||||
this.sendNodeData(id, parent as Element, node.data);
|
||||
|
|
@ -299,7 +371,7 @@ export default class Observer {
|
|||
}
|
||||
const attr = this.attributesList[id];
|
||||
if (attr !== undefined) {
|
||||
if (!(node instanceof Element)) {
|
||||
if (!this.isInstance(node, Element)) {
|
||||
throw 'commitNode: node is not an element';
|
||||
}
|
||||
for (const name of attr) {
|
||||
|
|
@ -307,7 +379,7 @@ export default class Observer {
|
|||
}
|
||||
}
|
||||
if (this.textSet.has(id)) {
|
||||
if (!(node instanceof Text)) {
|
||||
if (!this.isInstance(node, Text)) {
|
||||
throw 'commitNode: node is not a text';
|
||||
}
|
||||
// for text node id != 0, hence parent is Element
|
||||
|
|
@ -337,8 +409,44 @@ export default class Observer {
|
|||
this.clear();
|
||||
}
|
||||
|
||||
private iframeObservers: Observer[] = [];
|
||||
private handleIframe(iframe: HTMLIFrameElement): void {
|
||||
const handle = () => {
|
||||
const context = iframe.contentWindow as Window | null
|
||||
const id = this.app.nodes.getID(iframe)
|
||||
if (!context || id === undefined) { return }
|
||||
|
||||
const observer = new Observer(this.app, this.options, context)
|
||||
this.iframeObservers.push(observer)
|
||||
observer.observeIframe(id, context)
|
||||
}
|
||||
this.app.attachEventListener(iframe, "load", handle)
|
||||
handle()
|
||||
}
|
||||
|
||||
// TODO: abstract common functionality, separate FrameObserver
|
||||
private observeIframe(id: number, context: Window) {
|
||||
const doc = context.document;
|
||||
this.observer.observe(doc, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributeOldValue: false,
|
||||
characterDataOldValue: false,
|
||||
});
|
||||
this.bindTree(doc.documentElement);
|
||||
const docID = this.app.nodes.getID(doc.documentElement);
|
||||
if (docID === undefined) {
|
||||
console.log("Wrong")
|
||||
return;
|
||||
}
|
||||
this.app.send(CreateIFrameDocument(id,docID));
|
||||
this.commitNodes();
|
||||
}
|
||||
|
||||
observe(): void {
|
||||
this.observer.observe(document, {
|
||||
this.observer.observe(this.context.document, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
|
|
@ -347,11 +455,13 @@ export default class Observer {
|
|||
characterDataOldValue: false,
|
||||
});
|
||||
this.app.send(new CreateDocument());
|
||||
this.bindTree(document.documentElement);
|
||||
this.bindTree(this.context.document.documentElement);
|
||||
this.commitNodes();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.iframeObservers.forEach(o => o.disconnect());
|
||||
this.iframeObservers = [];
|
||||
this.observer.disconnect();
|
||||
this.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import App from '../app';
|
||||
import { CSSInsertRuleURLBased, CSSDeleteRule, TechnicalInfo } from '../../messages';
|
||||
import { getBaseURI } from '../utils';
|
||||
|
||||
export default function(app: App | null) {
|
||||
if (app === null) {
|
||||
|
|
@ -14,7 +13,7 @@ export default function(app: App | null) {
|
|||
const processOperation = app.safe(
|
||||
(stylesheet: CSSStyleSheet, index: number, rule?: string) => {
|
||||
const sendMessage = typeof rule === 'string'
|
||||
? (nodeID: number) => app.send(new CSSInsertRuleURLBased(nodeID, rule, index, getBaseURI()))
|
||||
? (nodeID: number) => app.send(new CSSInsertRuleURLBased(nodeID, rule, index, app.getBaseHref()))
|
||||
: (nodeID: number) => app.send(new CSSDeleteRule(nodeID, index));
|
||||
// TODO: Extend messages to maintain nested rules (CSSGroupingRule prototype, as well as CSSKeyframesRule)
|
||||
if (stylesheet.ownerNode == null) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { timestamp, isURL, getBaseURI } from '../utils';
|
||||
import { timestamp, isURL } from '../utils';
|
||||
import App from '../app';
|
||||
import { ResourceTiming, SetNodeAttributeURLBased } from '../../messages';
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ export default function (app: App): void {
|
|||
app.send(new ResourceTiming(timestamp(), 0, 0, 0, 0, 0, src, 'img'));
|
||||
}
|
||||
} else if (src.length < 1e5) {
|
||||
app.send(new SetNodeAttributeURLBased(id, 'src', src, getBaseURI()));
|
||||
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ export default function (app: App): void {
|
|||
return;
|
||||
}
|
||||
const src = target.src;
|
||||
app.send(new SetNodeAttributeURLBased(id, 'src', src, getBaseURI()));
|
||||
app.send(new SetNodeAttributeURLBased(id, 'src', src, app.getBaseHref()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,14 +72,23 @@ function getTargetLabel(target: Element): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
interface HeatmapsOptions {
|
||||
finder: FinderOptions,
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
selectorFinder: boolean | FinderOptions;
|
||||
heatmaps: boolean | HeatmapsOptions;
|
||||
}
|
||||
|
||||
export default function (app: App, opts: Partial<Options>): void {
|
||||
const options: Options = Object.assign(
|
||||
{
|
||||
selectorFinder: true,
|
||||
heatmaps: {
|
||||
finder: {
|
||||
threshold: 5,
|
||||
maxNumberOfTries: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
|
@ -106,9 +115,9 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
|
||||
const selectorMap: {[id:number]: string} = {};
|
||||
function getSelector(id: number, target: Element): string {
|
||||
if (options.selectorFinder === false) { return '' }
|
||||
if (options.heatmaps === false) { return '' }
|
||||
return selectorMap[id] = selectorMap[id] ||
|
||||
finder(target, options.selectorFinder === true ? undefined : options.selectorFinder);
|
||||
finder(target, options.heatmaps === true ? undefined : options.heatmaps.finder);
|
||||
}
|
||||
|
||||
app.attachEventListener(
|
||||
|
|
|
|||
|
|
@ -16,16 +16,6 @@ export function isURL(s: string): boolean {
|
|||
return s.substr(0, 8) === 'https://' || s.substr(0, 7) === 'http://';
|
||||
}
|
||||
|
||||
export function getBaseURI(): string {
|
||||
if (document.baseURI) {
|
||||
return document.baseURI;
|
||||
}
|
||||
// IE only
|
||||
return document.head
|
||||
?.getElementsByTagName("base")[0]
|
||||
?.getAttribute("href") || location.origin + location.pathname;
|
||||
}
|
||||
|
||||
export const IN_BROWSER = !(typeof window === "undefined");
|
||||
|
||||
export const log = console.log
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
export declare type Options = {
|
||||
root: Element;
|
||||
idName: (name: string) => boolean;
|
||||
className: (name: string) => boolean;
|
||||
tagName: (name: string) => boolean;
|
||||
attr: (name: string, value: string) => boolean;
|
||||
seedMinLength: number;
|
||||
optimizedMinLength: number;
|
||||
threshold: number;
|
||||
maxNumberOfTries: number;
|
||||
};
|
||||
export declare function finder(input: Element, options?: Partial<Options>): string;
|
||||
339
tracker/tracker/src/main/vendors/finder/finder.js
vendored
339
tracker/tracker/src/main/vendors/finder/finder.js
vendored
|
|
@ -1,339 +0,0 @@
|
|||
var Limit;
|
||||
(function (Limit) {
|
||||
Limit[Limit["All"] = 0] = "All";
|
||||
Limit[Limit["Two"] = 1] = "Two";
|
||||
Limit[Limit["One"] = 2] = "One";
|
||||
})(Limit || (Limit = {}));
|
||||
let config;
|
||||
let rootDocument;
|
||||
export function finder(input, options) {
|
||||
if (input.nodeType !== Node.ELEMENT_NODE) {
|
||||
throw new Error(`Can't generate CSS selector for non-element node type.`);
|
||||
}
|
||||
if ("html" === input.tagName.toLowerCase()) {
|
||||
return "html";
|
||||
}
|
||||
const defaults = {
|
||||
root: document.body,
|
||||
idName: (name) => true,
|
||||
className: (name) => true,
|
||||
tagName: (name) => true,
|
||||
attr: (name, value) => false,
|
||||
seedMinLength: 1,
|
||||
optimizedMinLength: 2,
|
||||
threshold: 1000,
|
||||
maxNumberOfTries: 10000,
|
||||
};
|
||||
config = Object.assign(Object.assign({}, defaults), options);
|
||||
rootDocument = findRootDocument(config.root, defaults);
|
||||
let path = bottomUpSearch(input, Limit.All, () => bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One)));
|
||||
if (path) {
|
||||
const optimized = sort(optimize(path, input));
|
||||
if (optimized.length > 0) {
|
||||
path = optimized[0];
|
||||
}
|
||||
return selector(path);
|
||||
}
|
||||
else {
|
||||
throw new Error(`Selector was not found.`);
|
||||
}
|
||||
}
|
||||
function findRootDocument(rootNode, defaults) {
|
||||
if (rootNode.nodeType === Node.DOCUMENT_NODE) {
|
||||
return rootNode;
|
||||
}
|
||||
if (rootNode === defaults.root) {
|
||||
return rootNode.ownerDocument;
|
||||
}
|
||||
return rootNode;
|
||||
}
|
||||
function bottomUpSearch(input, limit, fallback) {
|
||||
let path = null;
|
||||
let stack = [];
|
||||
let current = input;
|
||||
let i = 0;
|
||||
while (current && current !== config.root.parentElement) {
|
||||
let level = maybe(id(current)) || maybe(...attr(current)) || maybe(...classNames(current)) || maybe(tagName(current)) || [any()];
|
||||
const nth = index(current);
|
||||
if (limit === Limit.All) {
|
||||
if (nth) {
|
||||
level = level.concat(level.filter(dispensableNth).map(node => nthChild(node, nth)));
|
||||
}
|
||||
}
|
||||
else if (limit === Limit.Two) {
|
||||
level = level.slice(0, 1);
|
||||
if (nth) {
|
||||
level = level.concat(level.filter(dispensableNth).map(node => nthChild(node, nth)));
|
||||
}
|
||||
}
|
||||
else if (limit === Limit.One) {
|
||||
const [node] = level = level.slice(0, 1);
|
||||
if (nth && dispensableNth(node)) {
|
||||
level = [nthChild(node, nth)];
|
||||
}
|
||||
}
|
||||
for (let node of level) {
|
||||
node.level = i;
|
||||
}
|
||||
stack.push(level);
|
||||
if (stack.length >= config.seedMinLength) {
|
||||
path = findUniquePath(stack, fallback);
|
||||
if (path) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
current = current.parentElement;
|
||||
i++;
|
||||
}
|
||||
if (!path) {
|
||||
path = findUniquePath(stack, fallback);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
function findUniquePath(stack, fallback) {
|
||||
const paths = sort(combinations(stack));
|
||||
if (paths.length > config.threshold) {
|
||||
return fallback ? fallback() : null;
|
||||
}
|
||||
for (let candidate of paths) {
|
||||
if (unique(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function selector(path) {
|
||||
let node = path[0];
|
||||
let query = node.name;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const level = path[i].level || 0;
|
||||
if (node.level === level - 1) {
|
||||
query = `${path[i].name} > ${query}`;
|
||||
}
|
||||
else {
|
||||
query = `${path[i].name} ${query}`;
|
||||
}
|
||||
node = path[i];
|
||||
}
|
||||
return query;
|
||||
}
|
||||
function penalty(path) {
|
||||
return path.map(node => node.penalty).reduce((acc, i) => acc + i, 0);
|
||||
}
|
||||
function unique(path) {
|
||||
switch (rootDocument.querySelectorAll(selector(path)).length) {
|
||||
case 0:
|
||||
throw new Error(`Can't select any node with this selector: ${selector(path)}`);
|
||||
case 1:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function id(input) {
|
||||
const elementId = input.getAttribute("id");
|
||||
if (elementId && config.idName(elementId)) {
|
||||
return {
|
||||
name: "#" + cssesc(elementId, { isIdentifier: true }),
|
||||
penalty: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function attr(input) {
|
||||
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value));
|
||||
return attrs.map((attr) => ({
|
||||
name: "[" + cssesc(attr.name, { isIdentifier: true }) + "=\"" + cssesc(attr.value) + "\"]",
|
||||
penalty: 0.5
|
||||
}));
|
||||
}
|
||||
function classNames(input) {
|
||||
const names = Array.from(input.classList)
|
||||
.filter(config.className);
|
||||
return names.map((name) => ({
|
||||
name: "." + cssesc(name, { isIdentifier: true }),
|
||||
penalty: 1
|
||||
}));
|
||||
}
|
||||
function tagName(input) {
|
||||
const name = input.tagName.toLowerCase();
|
||||
if (config.tagName(name)) {
|
||||
return {
|
||||
name,
|
||||
penalty: 2
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function any() {
|
||||
return {
|
||||
name: "*",
|
||||
penalty: 3
|
||||
};
|
||||
}
|
||||
function index(input) {
|
||||
const parent = input.parentNode;
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
let child = parent.firstChild;
|
||||
if (!child) {
|
||||
return null;
|
||||
}
|
||||
let i = 0;
|
||||
while (child) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
i++;
|
||||
}
|
||||
if (child === input) {
|
||||
break;
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
function nthChild(node, i) {
|
||||
return {
|
||||
name: node.name + `:nth-child(${i})`,
|
||||
penalty: node.penalty + 1
|
||||
};
|
||||
}
|
||||
function dispensableNth(node) {
|
||||
return node.name !== "html" && !node.name.startsWith("#");
|
||||
}
|
||||
function maybe(...level) {
|
||||
const list = level.filter(notEmpty);
|
||||
if (list.length > 0) {
|
||||
return list;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function notEmpty(value) {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
function* combinations(stack, path = []) {
|
||||
if (stack.length > 0) {
|
||||
for (let node of stack[0]) {
|
||||
yield* combinations(stack.slice(1, stack.length), path.concat(node));
|
||||
}
|
||||
}
|
||||
else {
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
function sort(paths) {
|
||||
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b));
|
||||
}
|
||||
function* optimize(path, input, scope = {
|
||||
counter: 0,
|
||||
visited: new Map()
|
||||
}) {
|
||||
if (path.length > 2 && path.length > config.optimizedMinLength) {
|
||||
for (let i = 1; i < path.length - 1; i++) {
|
||||
if (scope.counter > config.maxNumberOfTries) {
|
||||
return; // Okay At least I tried!
|
||||
}
|
||||
scope.counter += 1;
|
||||
const newPath = [...path];
|
||||
newPath.splice(i, 1);
|
||||
const newPathKey = selector(newPath);
|
||||
if (scope.visited.has(newPathKey)) {
|
||||
return;
|
||||
}
|
||||
if (unique(newPath) && same(newPath, input)) {
|
||||
yield newPath;
|
||||
scope.visited.set(newPathKey, true);
|
||||
yield* optimize(newPath, input, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function same(path, input) {
|
||||
return rootDocument.querySelector(selector(path)) === input;
|
||||
}
|
||||
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/;
|
||||
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/;
|
||||
const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g;
|
||||
const defaultOptions = {
|
||||
"escapeEverything": false,
|
||||
"isIdentifier": false,
|
||||
"quotes": "single",
|
||||
"wrap": false
|
||||
};
|
||||
function cssesc(string, opt = {}) {
|
||||
const options = Object.assign(Object.assign({}, defaultOptions), opt);
|
||||
if (options.quotes != "single" && options.quotes != "double") {
|
||||
options.quotes = "single";
|
||||
}
|
||||
const quote = options.quotes == "double" ? "\"" : "'";
|
||||
const isIdentifier = options.isIdentifier;
|
||||
const firstChar = string.charAt(0);
|
||||
let output = "";
|
||||
let counter = 0;
|
||||
const length = string.length;
|
||||
while (counter < length) {
|
||||
const character = string.charAt(counter++);
|
||||
let codePoint = character.charCodeAt(0);
|
||||
let value = void 0;
|
||||
// If it’s not a printable ASCII character…
|
||||
if (codePoint < 0x20 || codePoint > 0x7E) {
|
||||
if (codePoint >= 0xD800 && codePoint <= 0xDBFF && counter < length) {
|
||||
// It’s a high surrogate, and there is a next character.
|
||||
const extra = string.charCodeAt(counter++);
|
||||
if ((extra & 0xFC00) == 0xDC00) {
|
||||
// next character is low surrogate
|
||||
codePoint = ((codePoint & 0x3FF) << 10) + (extra & 0x3FF) + 0x10000;
|
||||
}
|
||||
else {
|
||||
// It’s an unmatched surrogate; only append this code unit, in case
|
||||
// the next code unit is the high surrogate of a surrogate pair.
|
||||
counter--;
|
||||
}
|
||||
}
|
||||
value = "\\" + codePoint.toString(16).toUpperCase() + " ";
|
||||
}
|
||||
else {
|
||||
if (options.escapeEverything) {
|
||||
if (regexAnySingleEscape.test(character)) {
|
||||
value = "\\" + character;
|
||||
}
|
||||
else {
|
||||
value = "\\" + codePoint.toString(16).toUpperCase() + " ";
|
||||
}
|
||||
}
|
||||
else if (/[\t\n\f\r\x0B]/.test(character)) {
|
||||
value = "\\" + codePoint.toString(16).toUpperCase() + " ";
|
||||
}
|
||||
else if (character == "\\" || !isIdentifier && (character == "\"" && quote == character || character == "'" && quote == character) || isIdentifier && regexSingleEscape.test(character)) {
|
||||
value = "\\" + character;
|
||||
}
|
||||
else {
|
||||
value = character;
|
||||
}
|
||||
}
|
||||
output += value;
|
||||
}
|
||||
if (isIdentifier) {
|
||||
if (/^-[-\d]/.test(output)) {
|
||||
output = "\\-" + output.slice(1);
|
||||
}
|
||||
else if (/\d/.test(firstChar)) {
|
||||
output = "\\3" + firstChar + " " + output.slice(1);
|
||||
}
|
||||
}
|
||||
// Remove spaces after `\HEX` escapes that are not followed by a hex digit,
|
||||
// since they’re redundant. Note that this is only possible if the escape
|
||||
// sequence isn’t preceded by an odd number of backslashes.
|
||||
output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) {
|
||||
if ($1 && $1.length % 2) {
|
||||
// It’s not safe to remove the space, so don’t.
|
||||
return $0;
|
||||
}
|
||||
// Strip the space.
|
||||
return ($1 || "") + $2;
|
||||
});
|
||||
if (!isIdentifier && options.wrap) {
|
||||
return quote + output + quote;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
|
@ -279,14 +279,16 @@ function notEmpty<T>(value: T | null | undefined): value is T {
|
|||
return value !== null && value !== undefined
|
||||
}
|
||||
|
||||
function* combinations(stack: Node[][], path: Node[] = []): Generator<Node[]> {
|
||||
function combinations(stack: Node[][], path: Node[] = []): Node[][] {
|
||||
const paths: Node[][] = []
|
||||
if (stack.length > 0) {
|
||||
for (let node of stack[0]) {
|
||||
yield* combinations(stack.slice(1, stack.length), path.concat(node))
|
||||
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)))
|
||||
}
|
||||
} else {
|
||||
yield path
|
||||
paths.push(path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function sort(paths: Iterable<Path>): Path[] {
|
||||
|
|
@ -298,29 +300,31 @@ type Scope = {
|
|||
visited: Map<string, boolean>
|
||||
}
|
||||
|
||||
function* optimize(path: Path, input: Element, scope: Scope = {
|
||||
function optimize(path: Path, input: Element, scope: Scope = {
|
||||
counter: 0,
|
||||
visited: new Map<string, boolean>()
|
||||
}): Generator<Node[]> {
|
||||
}): Node[][] {
|
||||
const paths: Node[][] = []
|
||||
if (path.length > 2 && path.length > config.optimizedMinLength) {
|
||||
for (let i = 1; i < path.length - 1; i++) {
|
||||
if (scope.counter > config.maxNumberOfTries) {
|
||||
return // Okay At least I tried!
|
||||
return paths // Okay At least I tried!
|
||||
}
|
||||
scope.counter += 1
|
||||
const newPath = [...path]
|
||||
newPath.splice(i, 1)
|
||||
const newPathKey = selector(newPath)
|
||||
if (scope.visited.has(newPathKey)) {
|
||||
return
|
||||
return paths
|
||||
}
|
||||
if (unique(newPath) && same(newPath, input)) {
|
||||
yield newPath
|
||||
paths.push(newPath)
|
||||
scope.visited.set(newPathKey, true)
|
||||
yield* optimize(newPath, input, scope)
|
||||
paths.push(...optimize(newPath, input, scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function same(path: Path, input: Element) {
|
||||
|
|
|
|||
|
|
@ -885,3 +885,19 @@ export const MouseClick = bindNew(_MouseClick);
|
|||
classes.set(69, MouseClick);
|
||||
|
||||
|
||||
class _CreateIFrameDocument implements Message {
|
||||
readonly _id: number = 70;
|
||||
constructor(
|
||||
public frameID: number,
|
||||
public id: number
|
||||
) {}
|
||||
encode(writer: Writer): boolean {
|
||||
return writer.uint(70) &&
|
||||
writer.uint(this.frameID) &&
|
||||
writer.uint(this.id);
|
||||
}
|
||||
}
|
||||
export const CreateIFrameDocument = bindNew(_CreateIFrameDocument);
|
||||
classes.set(70, CreateIFrameDocument);
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue