diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..fcdf80c --- /dev/null +++ b/.babelrc @@ -0,0 +1,26 @@ +{ + "presets": [ + "stage-0", + "es2015", + "env" + ], + "plugins": [ + [ + "transform-es3-property-literals", + "transform-es3-member-expression-literals", + [ + "transform-es2015-modules-commonjs", + { + "loose": true + } + ], + "component", + [ + { + "libraryName": "element-ui", + "styleLibraryName": "~./src/editor/theme/element-ui" + } + ] + ] + ] +} \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..8dd9905 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,123 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "es6": true, + "node": true + }, + "globals": { + "XDomainRequest": true, + "window": true, + "wx": true + }, + "extends": "eslint:recommended", + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 7, + "ecmaFeatures": { + // lambda表达式 + "arrowFunctions": true, + // 解构赋值 + "destructuring": true, + // class + "classes": true, + // http://es6.ruanyifeng.com/#docs/function#函数参数的默认值 + "defaultParams": true, + // 块级作用域,允许使用let const + "blockBindings": true, + // 允许使用模块,模块内默认严格模式 + "modules": true, + // 允许字面量定义对象时,用表达式做属性名 + // http://es6.ruanyifeng.com/#docs/object#属性名表达式 + "objectLiteralComputedProperties": true, + // 允许对象字面量方法名简写 + /*var o = { + method() { + return "Hello!"; + } + }; + + 等同于 + + var o = { + method: function() { + return "Hello!"; + } + }; + */ + "objectLiteralShorthandMethods": true, + /* + 对象字面量属性名简写 + var foo = 'bar'; + var baz = {foo}; + baz // {foo: "bar"} + + // 等同于 + var baz = {foo: foo}; + */ + "objectLiteralShorthandProperties": true, + // http://es6.ruanyifeng.com/#docs/function#rest参数 + "restParams": true, + // http://es6.ruanyifeng.com/#docs/function#扩展运算符 + "spread": true, + // http://es6.ruanyifeng.com/#docs/iterator#for---of循环 + "forOf": true, + // http://es6.ruanyifeng.com/#docs/generator + "generators": true, + // http://es6.ruanyifeng.com/#docs/string#模板字符串 + "templateStrings": true, + "superInFunctions": true, + // http://es6.ruanyifeng.com/#docs/object#对象的扩展运算符 + "experimentalObjectRestSpread": true, + "experimentalDecorators": true + }, + "sourceType": "module" + }, + "rules": { + "indent": [ 1, 2, { + "SwitchCase": 1 + } + ], + // 文件末尾强制换行 + "eol-last": 1, + // 使用 === 替代 == + "eqeqeq": [ + 1, + "allow-null" + ], + // 控制逗号在行尾出现还是在行首出现 + // http://eslint.org/docs/rules/comma-style + "comma-style": [ + 1, + "last" + ], + "linebreak-style": [ + 1, + "unix" + ], + "quotes": [ + 1, + "single" + ], + "semi": [ + 1, + "never" + ], + "no-extra-semi": 0, + "semi-spacing": 0, + "no-alert": 0, + "no-array-constructor": 1, + "no-caller": 1, + "no-catch-shadow": 0, + "no-cond-assign": 1, + "no-console": 0, + "no-constant-condition": 0, + "no-continue": 0, + "no-control-regex": 1, + "no-debugger": 1, + "no-delete-var": 1, + "no-mixed-spaces-and-tabs": 1, + "no-unused-vars": 1, + "comma-dangle": 1 + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acacf04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +/node_modules/ +/build/ +/libs/ +/lib/ +!/lib/.gitkeep +/temp/ +/private/ +# Logs +*.log +.DS_Store +*.rdb + +# config etc +.idea/ +config.js +.vscode/ +!src/config.js + + +npm-debug.log* +/package-lock.json +/yarn-error.log +/yarn.lock \ No newline at end of file diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.jshintignore @@ -0,0 +1 @@ +node_modules diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..997b3f7 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,10 @@ +{ + "node": true, + + "curly": true, + "latedef": true, + "quotmark": true, + "undef": true, + "unused": true, + "trailing": true +} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f9a980a --- /dev/null +++ b/.npmignore @@ -0,0 +1,25 @@ +/tests/ +/src/ +/vender/ +/vendor/ +/build/ +/temp/ +/bin/ +/private/ +/.* +/*.sh + +# === vscode ==== +/.vscode/ + +/.babelrc + +# === WebStorm === +/.idea/ +/testServer.js +/webpack.config.js +/yarn-error.log +/yarn.lock +/config.js +/sugoio-jslib-snippet.js +compile diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab8d66c --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Sugo JavaScript Library +The Sugo JavaScript Library is a set of methods attached to a global `sugoio` object +intended to be used by websites wishing to send data to Sugo projects. A full reference +is available + + +```sh +npm install + +npm run build +``` + +You can then require the lib like a standard Node.js module: + +```javascript +var sugoio = require('sugo-sdk-js'); + +sugoio.init("YOUR_TOKEN"); +sugoio.track("An event"); +``` + +## Building bundles for release +- Install development dependencies: `npm install` +- Build: `npm run deploy` +- publish `npm publish` + +## custom config +``` +cp config.default.js config.js +//then edit config.js +``` +## Running tests +- Install development dependencies: `npm install` +- Start test server: `npm test` +- Browse to [http://localhost:4000/tests/](http://localhost:4000/tests/) and choose a scenario to run + +In the future we plan to automate the last step with a headless browser to streamline development (although +Sugo production releases are tested against a large matrix of browsers and operating systems). + +``` +sugoio.init('TOKEN(APPID)', { +  project_id: '项目ID', + loaded: function(lib) { + sugoio.time_event('页面停留时间') + sugoio._.register_event(window, 'beforeunload', function(){ + sugoio.track('页面停留时间',{page: location.pathname}) + }, false, true) + } +}); +//给每条上报记录添加自定义维度 +sugoio.register({CS1:'userid',CS2: 'xxxx'}); + +//手动埋点上报数据 +sugoio.track("事件名称", { + properties //附带属性 +}) +``` + +## SDK上报维度 +|维度名|维度标题|类型|iOS|Android|Web|维度描述| +|:---|:---|:---:|:---:|:---:|:---:|:---| +|__time | 服务端时间 | date | √ | √ | √ | 服务端时间 | +|sugo_nation | 国家 | string | √ | √ | √ | 用户所在国家(ip反解析) | +|sugo_province | 省份 | string | √ | √ | √ | 用户所在省份(ip反解析) | +|sugo_city | 城市 | string | √ | √ | √ | 用户所在城市(ip反解析) | +|sugo_district | 地区 | string | √ | √ | √ | 用户所在地区(ip反解析) | +|sugo_area | 区域 | string | √ | √ | √ | 用户所在区域(ip反解析) | +|sugo_latitude | 纬度 | string | √ | √ | √ | 纬度(ip反解析) | +|sugo_longitude | 经度 | string | √ | √ | √ | 经度(ip反解析) | +|sugo_city_timezone | 城市时区 | string | √ | √ | √ | 所在时区代表城市(ip反解析) | +|sugo_timezone | 时区 | string | √ | √ | √ | 所在时区(ip反解析) | +|sugo_phone_code | 国际区号 | string | √ | √ | √ | 国际区号(ip反解析) | +|sugo_nation_code | 国家代码 | string | √ | √ | √ | 国家代码(ip反解析) | +|sugo_continent | 所在大洲代码 | string | √ | √ | √ | 所在大洲(ip反解析) | +|sugo_administrative | 行政区划代码 | string | √ | √ | √ | 中国行政区划代码(ip反解析) | +|sugo_operator | 宽带运营商 | string | √ | √ | √ | 用户所在运营商(ip反解析) | +|sugo_ip | 客户端IP | string | √ | √ | √ | 客户端IP(nginx)Header的X-Real-IP,或者 remote_addr | +|browser | 浏览器名称 | string | x | x | √ | 浏览器名称 | +|browser_version | 浏览器版本 | string | x | x | √ | 浏览器版本 | +|app_name | 应用名称 | string | √ | √ | x | 系统或app的系统名称(应用安装后的名字) | +|app_version | 应用版本 | string | √ | √ | x | 系统或app的系统版本 | +|app_build_number | 应用编译版本 | string | √ | √ | x | 应用编译版本 | +|session_id | 会话ID | string | √ | √ | √ | 会话id(app每次打开自动生成,web每次打开浏览器自动生成,如果不关闭浏览器,一天后自动生成新的session_id) | +|network | 网络类型 | string | √ | √ | x | 用户使用的网络类型(wifi,2g,3g,4g) | +|device_id | 设备ID | string | √ | √ | x | Android: IMEI > mac > android_id/iOS: IDFA > IDFV | +|bluetooth_version | 蓝牙版本 | string | x | √ | x | 用户蓝牙版本 | +|has_bluetooth | 蓝牙功能 | string | x | √ | x | 用户是否有蓝牙 | +|device_brand | 品牌 | string | √ | √ | √ | 用户电脑、平板、或手机牌子 | +|device_model | 品牌型号 | string | √ | √ | √ | 用户电脑、平板、或手机型号 | +|system_name | 操作系统名称 | string | √ | √ | √ | 客户端操作系统名称(Android,iOS, Windows, macOS 等) | +|system_version | 操作系统版本 | string | √ | √ | √ | 客户端操作系统版本 | +|radio | 通信协议 | string | √ | √ | x | 通信协议(gsm,cdma,sip等) | +|carrier | 手机运营商 | string | √ | √ | x | 运营商(中国移动,中国联通等) | +|screen_dpi | 屏幕DPI | int | x | √ | √ | 客户端分辨率每平方英寸的点数 | +|screen_pixel | 屏幕分辨率 | string | √ | √ | √ | 客户端屏幕分辨率(格式:屏幕宽度*屏幕高度) | +|event_time | 客户端事件时间 | date | √ | √ | √ | 客户端事件发生时间(unix毫秒数) | +|current_url | 当前请求地址 | string | x | x | √ | 客户端当前请求地址 | +|referring_domain | 客户引荐域名 | string | x | x | √ | 客户引荐域名(上一访问页面地址栏域名) | +|host | 客户端域名 | string | x | x | √ | 浏览器地址栏域名 | +|distinct_id | 用户唯一ID | string | √ | √ | √ | web首次访问生成用户唯一id并存在cookies,清除cookies算一个新的用户,app首次打开时候会生成一个用户唯一id,重装算另一个用户 | +|has_nfc | NFC功能 | string | x | √ | x | 是否有NFC功能 | +|has_telephone | 电话功能 | string | x | √ | x | 是否有电话功能 | +|has_wifi | WIFI功能 | string | √ | √ | x | 是否有wifi功能 | +|manufacturer | 设备制造商 | string | √ | √ | x | 设备制造商(meizu,huawei等) | +|duration | 停留时间 | float | √ | √ | √ | 页面停留时间(停留事件才有) | +|sdk_version | SDK版本 | string | √ | √ | √ | sdk版本 | +|page_name | 页面名称 | string | √ | √ | √ | 页面名称或窗口名称,可在可视化埋点界面设置,默认取title | +|path_name | 页面路径 | string | √ | √ | √ | 页面路径(web取域名之后的路径) | +|event_id | 事件ID | string | √ | √ | √ | 事件ID,可视化埋点绑定的事件才上报 | +|event_name | 事件名称 | string | √ | √ | √ | 事件名称 | +|event_type | 事件类型 | string | √ | √ | √ | 事件类型click、focus、submit、change | +|event_label | 事件源文本 | string | x | √ | √ | 事件源文本(如按钮上的文字) | +|sugo_lib | sdk类型 | string | √ | √ | √ | sdk类型: web/Objective-C/Swift/Android/Wx-mini | +|token | 应用ID | string | √ | √ | √ | 应用ID | +|from_binding | 是否绑定事件 | string | √ | √ | x | 是否绑定事件(null或者true)(用于区分是绑定的,还是系统自动上报的,比如浏览、启动为自动上报,绑定取值1,自动上报取值0) | +|google_play_service | GooglePlay服务 | string | x | √ | x | 是否有GooglePlay服务 | +|page_category | 页面分类 | string | √ | √ | √ | 页面分类 | +|first_visit_time | 首次访问时间 | date | √ | √ | √ | 首次访问时间(用户唯一ID的首次访问时间) | +|first_login_time | 首次登陆时间 | date | √ | √ | √ | 首次登陆时间(业务ID的首次登录时间,需要把业务ID 传给sdk) | + diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..a0480fc --- /dev/null +++ b/changelog.md @@ -0,0 +1,198 @@ +# v1.5.0 + +- 每次打开页面设置根据当前url重新匹配页面 +- 增加 editor-lite.js打包(不含vuejs),根据环境动态加载editor +- 将原来同类对象的深度监听改为浅监听,点击同类勾选重新计算同类path(解决某些环境同类堆栈溢出bug) +- 支持根据enable_hash监听hashchange事件,可视化埋点重新渲染埋点事件 +- 支持根据enable_hash监听hashchange事件,sdk上报动态加载新配置并绑定事件 + +# v1.4.1 + +- add encodeURIComponent for decide +- add `event_type`, `path_name`, `page_category`, default value for track + +# v1.4.0 + +- sugoio.init支持enable_hash参数, 主要针对vue等单页应用页面路径规则添加hash(pathname=location.pathname + location.hash)规则 + +```js + sugoio.init('ee7469aca94d082748418945eccd13eb', { + project_id: 'com_HyoaKhQMl_project_ry_2vnt1M', + enable_hash: true, + ... + }); +``` +# v1.3.26 + +- 添加sugoio.updateSessionId()方法,更新cookie中的session_id值 +- 小程序增加接口 + - sugoio.time_event 停留记时函数 + - sugoio.register 超级属性设置(存在覆盖) + - sugoio.register_once 超级属性设置(存在则不添加) + - sugoio.unregister 删除超级属性 + +# v1.3.25(2018-06-19) + + - 支持简单的关联元素同类埋点取值 + +# v1.3.23,24(2018-06-19) + +- 完善小程序配置 +- 压缩打包微信小程序 + +# v1.3.22(2018-06-19) + +- 增加微信小程序代码埋点sdk(libs/sugo-wx-mini.program.min.js) + +# v1.3.21(2018-06-19) + +- 修复埋点与iscroll兼容问题 + +# v1.3.20(2018-06-11) + +- 修复cdn初始化参数未覆盖问题 +- 修复使用配置app_host拉取页面配置 + +# v1.3.18(2018-06-07) + +- 修复sdk ie8下userAgent报错的问题 + +# v1.3.17(2018-04-27) + +- 修复sdk的install函数, 确保每次都正确写入app_host等自定义配置 + +# v1.3.16(2017-11-21) ++ 增加`首次访问`,`首次登录`事件类型 ++ 上报数据增加`first_visit_time`, `first_login_time`字段, + 分别对应`首次访问时间`,`首次登录时间` ++ 增加`sugoio.track_first_time`, `sugoio.clear_first_login`接口 + +### 接口定义 +```typescript +interface SugoIO { + // 上报用户首次登录状态,调用该接口,执行步骤如下: + // 1. 如果user_id与持久化存储空间中的user_id一致,直接调用callback, 返回 SugoIO + // 2. 查询该用户是否为首次登录 + // 2.1 请求成功 + // 2.1.1 首次登录 + // 2.1.2 将user_id,首次登录时间与user_real_dimension(如果提供了)写入存储空间 + // 2.1.3 发送首次登录事件 + // 2.2.4 执行callback(null) + // 2.2 请求错误 + // 2.2.1 执行callback(err) + // 3. 返回SugoIO + track_first_time( + // 用户真实id + user_id: string, + // 用户真实id所属维度名,如果没有提供,上报数据中不会包含user_id + user_real_dimension?: string | ((err: Error |null) => any), + // 回调函数,如果出错,err为Error类型,正常完成时err为null + callback?: (err: Error | null)=> any + ): SugoIO + + // 清除用户登录状态,返回SugoIO + clear_first_login(user_id: string): SugoIO +} +``` +### 示例 +```js +sugoio.track_first_time('test_user_id', 'real_user_id', function (err) { console.log(err) }); +sugoio.track_first_time('test_user_id', 'real_user_id'); +sugoio.track_first_time('test_user_id', function (err) { console.log(err) }); +sugoio.clear_first_login('test_user_id') +``` + +# v1.3.0 (2017-11-16) ++ 上报数据代码支持ie8+ + 如果要支持ie8及ie9,必须使用ie __标准模式__,需要在html中添加如下代码: + ```html + + + + + + + ``` + 该内容定义文档使用ie标准模式渲染,这是上报数据能在ie8\9下运行的必要条件。 + 该设置是兼容ie网站的渲染与js引擎的最优设置方式。 + 当然你也可以使用其他设置使得你的网站采用标准模式渲染,详细设置参考: + + + [合法DOCTYPE列表](https://www.w3.org/QA/2002/04/valid-dtd-list.html) + + https://msdn.microsoft.com/en-us/library/ff955275(v=vs.85).aspx + + https://hsivonen.fi/doctype/ + + https://hsivonen.fi/doctype/#ie8modes + + https://hsivonen.fi/doctype/ie8-mode.png + ++ 默认关闭高级选项,删除开启高级选项switch,由用户输入的code与关联元素决定是否开启高级选项 ++ 用户从高级选项切换到其他编辑项时,自动切换页面状态由关联元素选择状态变为编辑状态 ++ 修复埋点事件页面路径属性展示错误 + +__ie8\9兼容模式下上报数据的支持视需求强烈而定__ + +# v1.2.33 (2017-11-13) ++ 修复部署代码多次写入配置的bug。 ++ UI优化:埋点圈选按钮增加生效与无效的状态。 + +# v1.2.23 (2017-11-11) ++ 重构部署代码逻辑,修复隐患 + +# v1.2.8 (2017-11-02) ++ UI升级 ++ 默认开启事件高级选项 ++ 关联元素同类有效配置默认变为false ++ 非编辑状态下,鼠标移动时不再圈选页面元素 + +# v1.2.7(2017-11-01) ++ 开放Debug工具函数 ++ 修复部署代码bug + +# v1.2.6 (2017-10-16) ++ 兼容旧页面配置 ++ 兼容旧页面事件 ++ 优化拖动元素,所有的元素都只能拖动标题部份,以提升输入内容时体验。 + +# v1.2.5 (2017-09-26) ++ 删除重复内容字段:referrer、sugo_user_agent、sugo_args + +# v1.2.4 (2017-09-23) ++ 同类元素增加可选限制配置,包括id、class与自定义 ++ 页面路径最大长度由50变为2048 + +# v1.2.2 (2017-09-20) ++ 修复app_host配置:如果app_host中没有 protocol,则会加上当前页面的 protocol。 + 该配置会影响sugoio-last.min.js加载以及上报与请求数据的接口。 + +# v1.2.1 (2017-09-18) ++ 优化路径生成规则: + + 如果生成的最优路径对应多个元素,生成全量路径来代替之前的报错 + + 同类元素使用全量路径匹配 + ++ 增强同类元素选择 + + 同类元素路径与元素本身路径分离 + + 同类元素各个元素的父级影响范围可任意定制 + ++ 增加忽略编辑器操作:按住`ctrl`(windows\linux)或`command`(OSX)键 ++ UI与交互优化、统一 + +# v1.0.0 (2017-08-29) +### 新增功能 ++ 新增页面元素关联维度功能 ++ 新增页面路径通配符功能 ++ 新增页面分类名称匹配功能 +### 优化 ++ UI全部重构 ++ 代码使用vue重构 ++ 性能优化 ++ 其他bug修复 + +# v0.2.6 (2017-06-07) ++ 修复多次调用`sugoio.init`产生的数据被覆盖的bug。 ++ 点击编辑器之外的元素时,`已创建事件`面板会被隐藏。 ++ 修复删除已埋点事件时UI有时不能同步的问题。 ++ 删除此前es5\6混合代码,统一转为es5,增加可读性及性能。 ++ 修复一些功能函数中的bug。 ++ 更详细的日志,以便排查错误。 + +# v0.2.5 (2017-05-25) ++ 添加`system_name`字段 + diff --git a/compile b/compile new file mode 100644 index 0000000..06a02a2 --- /dev/null +++ b/compile @@ -0,0 +1,23 @@ +#!/bin/bash -eu +echo 'rm build/*' +rm -rf ./build/ +mkdir -p ./build + +if [ $@ = 'build' ]; then + echo 'rm libs/*' + rm -rf ./libs/ + mkdir -p ./libs +fi + +# sugoio-core +echo $@ 'Bundling globals and editor files' +npm run $@ + +if [ $? -ne 0 ]; then + echo "Bundling was get error and exit" + exit 1 +fi + +echo "Copy to libs" +cp ./build/sugo-sdk.js ./libs/sugoio-latest.min.buf.js +cp ./build/sugo-sdk.js ./libs/sugoio-latest.min.js diff --git a/config.default.js b/config.default.js new file mode 100644 index 0000000..d8a09c0 --- /dev/null +++ b/config.default.js @@ -0,0 +1,30 @@ +/** + * @typedef {Object} SugoIOSDKJSConfig + * @property {boolean} debug + * @property {string} encode_type + * @property {string} api_host + * @property {string} app_host + * @property {string} decide_host + * @property {string} namespace + * @property {loaded} dimensions + */ +module.exports = { + + debug: false, + + encode_type: 'plain',// 默认json以前的方式 plain=新的数据包模式 + + //for /track /engage 数据上报 + api_host: 'collect.sugo.io', + + //for media 静态资源 + app_host: 'localhost:8080', + + enable_hash: false, + + //可视化埋点 + decide_host: 'localhost:8080', + + // 插件所属命名空间,在install的时候会传入 + namespace: '' +} diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..20be3c6 --- /dev/null +++ b/constants.js @@ -0,0 +1,42 @@ +/** + * @Author sugo.io + * @Date 17-10-30 + * @desc 常量配置,如果没有把握,不要修改,可以新增 + */ + +module.exports = { + // sugoio 实例挂载到window对象上的属性 + PRIMARY_INSTANCE_NAME: 'sugoio', + // 全局匹配挂荎sugoio的属性 + INJECT_CONFIG_PROP_KEY: '__SUGO__INJECT__CONFIG__', + + /* sugoio-core */ + INIT_MODULE: 0, + INIT_SNIPPET: 1, + + SET_QUEUE_KEY: '__sgs', + SET_ONCE_QUEUE_KEY: '__sgso', + ADD_QUEUE_KEY: '__sga', + APPEND_QUEUE_KEY: '__sgap', + UNION_QUEUE_KEY: '__sgu', + SET_ACTION: 'set', + SET_ONCE_ACTION: 'set_once', + ADD_ACTION: 'add', + APPEND_ACTION: 'append', + UNION_ACTION: 'union', + PEOPLE_DISTINCT_ID_KEY: 'people_distinct_id', + ALIAS_ID_KEY: '__alias', + CAMPAIGN_IDS_KEY: '__cmpns', + EVENT_TIMERS_KEY: '__timers', + + /* 首次访问与首次登录 */ + // 用户真实id存储维度名前缀 + // 生成内部命名key,以免用户通过register方法覆盖 + PEOPLE_REAL_USER_ID_DIMENSION_PREFIX: '__rpx', + FIRST_VISIT_TIME: 'first_visit_time', + FIRST_LOGIN_TIME: 'first_login_time', + + /* standard event name */ + FIRST_VISIT_EVENT_NAME: '首次访问', + FIRST_LOGIN_EVENT_NAME: '首次登录' +} diff --git a/install.js b/install.js new file mode 100644 index 0000000..cdcdd5f --- /dev/null +++ b/install.js @@ -0,0 +1,91 @@ +/** + * @Author sugo.io + * @Date 17-11-10 + */ + +const CONSTANTS = require('./constants') +const DefConf = require('./config.default') +const fs = require('fs') +const path = require('path') + +const namespace = 'sugo-sdk-js' + +/** + * 需要扩展到应用的资源 + * @typedef {Object} SugoIOExtensionExtract + * @property {Array<{path:string, file:string}>} [assets] - 静态资源文件或目录 + */ + +/** + * @typedef {function} SugoIOExtension + * @param {Koa} app + * @param {Router} router + * @param {Object} database + * @param {Object} config + * @param {Object} dependencies + * @return {Promise} + */ + +/** + * @function {SugoIOExtension} + * 安装过程是将 SugoIO/config.js 中的`websdk_api_host`, `websdk_app_host`以及`websdk_decide_host` + * 写入到已发布的 ./libs/sugoio-latest.min.js 中。 + * 由于url的复制性造成替换正则难以书写,且为全局多次替换,极易出错。 + * 所以直接将配置写入到源代码最前面,以保证在sdk所有代码之前写入配置。 + */ +function install(app, router, database, config, dependencies) { + return new Promise((resolve, reject) => { + let conf + try { + conf = require(path.join(__dirname, './config.js')) + } catch (e) { + conf = {} + console.log(`${namespace} no custom config`) + } finally { + // 合并 config,优先级为 config.default < config.js < config + conf = Object.assign(DefConf, conf, { namespace }) + } + + // 提取sugoio配置,写入到本地config中 + const hostsKeys = ['api_host', 'app_host', 'decide_host'] + + hostsKeys.forEach((key) => { + const ck = 'websdk_' + key + if (config[ck]) { + conf[key] = config[ck] + } + }) + + const injectScript = ` + (function(){ + var INJECT_CONFIG = ${JSON.stringify(conf)}; + var SugoIO = window['${CONSTANTS.PRIMARY_INSTANCE_NAME}'] || (window['${CONSTANTS.PRIMARY_INSTANCE_NAME}'] = {}); + SugoIO['${CONSTANTS.INJECT_CONFIG_PROP_KEY}'] = INJECT_CONFIG; + })(); + ` + const rBuf = path.join(__dirname, './libs/sugoio-latest.min.buf.js') + const wBuf = path.join(__dirname, './libs/sugoio-latest.min.js') + let reader = fs.createReadStream(rBuf) + let writer = fs.createWriteStream(wBuf) + + writer.write(injectScript) + reader.pipe(writer) + + writer.on('finish', () => { + console.log(`--------------${namespace} installed---------------`) + // 静态资源可能会放在CDN,所以不能使用动态的 namespace + // 这会造成每次服务重器时url都发生变化 + // 进而造成CDN缓存失效 + resolve({ assets: [] }) + }) + + writer.on('error', (err) => { + reject(err.message) + }) + }) +} + +module.exports = { + install, + namespace +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..57681b7 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "sugo-sdk-js", + "version": "1.5.0", + "description": "web可视化埋点", + "main": "install.js", + "scripts": { + "start": "pushd examples", + "server": "echo 'Browse to localhost:4000/tests' && node testServer.js", + "lint": "./node_modules/eslint/bin/eslint.js ./src", + "theme:conf": "./node_modules/.bin/et -i ./src/editor/theme/element-variables.css", + "dev": "./compile build:dev", + "deploy": "./compile build", + "theme": "./node_modules/.bin/et -c ./src/editor/theme/element-variables.css -o ./src/editor/theme/element-ui", + "build:dev": "NODE_ENV=development ./node_modules/.bin/webpack --watch --progress", + "build": "NODE_ENV=production ./node_modules/.bin/webpack -p --progress", + "snippet": "./node_modules/.bin/uglifyjs ./sugoio-jslib-snippet.js -m -c --ie8 --comments '/^/*!/' --timings -o ./sugoio-jslib-snippet.min.js", + "validate": "npm ls" + }, + "dependencies": { + "css-selector-parser": "0.0.5", + "element-ui": "2.4.6", + "sugo-css-path": "^1.0.0", + "sugo-sdk-js-utils": "0.0.15", + "ua-parser-js": "^0.7.18", + "vue": "2.5.17" + }, + "devDependencies": { + "babel-core": "^6.26.3", + "babel-eslint": "^7.2.3", + "babel-loader": "^7.1.5", + "babel-plugin-component": "^1.1.1", + "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", + "babel-plugin-transform-es3-member-expression-literals": "^6.22.0", + "babel-plugin-transform-es3-property-literals": "^6.22.0", + "babel-preset-env": "^1.7.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-stage-0": "^6.24.1", + "babel-preset-vue-app": "^2.0.0", + "cookie-parser": "^1.4.3", + "css-loader": "^1.0.0", + "element-theme": "^2.0.1", + "element-theme-default": "^1.4.13", + "eslint": "^5.4.0", + "express": "^4.16.3", + "file-loader": "^0.8.5", + "lodash": "^4.17.11", + "lz-string": "^1.4.4", + "morgan": "^1.9.0", + "node-sass": "^4.9.3", + "precommit-hook": "^3.0.0", + "sass-loader": "^7.1.0", + "style-loader": "^0.22.1", + "sugo-analytics-common-tools": "^1.1.1", + "uglify-js": "^3.4.8", + "uglifyjs-webpack-plugin": "1.3.0", + "url-loader": "^1.1.1", + "vue-loader": "^12.1.0", + "vue-template-compiler": "2.5.17", + "webpack": "^2.7.0" + }, + "author": "WuQic", + "license": "ISC", + "pre-commit": [ + "lint", + "validate", + "test" + ] +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e2ef64d --- /dev/null +++ b/src/config.js @@ -0,0 +1,13 @@ +/** + * @Author sugo.io + * @Date 17-11-11 + * @description 获取服务器配置 + */ + +var CONSTANTS = require('../constants') +var PRIMARY_INSTANCE_NAME = CONSTANTS.PRIMARY_INSTANCE_NAME +var INJECT_CONFIG_PROP_KEY = CONSTANTS.INJECT_CONFIG_PROP_KEY +var SugoIO = window[PRIMARY_INSTANCE_NAME] || (window[PRIMARY_INSTANCE_NAME] = {}) + +/** @type {SugoIOSDKJSConfig} */ +module.exports = SugoIO[INJECT_CONFIG_PROP_KEY] || (SugoIO[INJECT_CONFIG_PROP_KEY] = {}) diff --git a/src/css-path/base.js b/src/css-path/base.js new file mode 100644 index 0000000..49d459e --- /dev/null +++ b/src/css-path/base.js @@ -0,0 +1,111 @@ +/** + * @Author sugo.io + * @Date 17-9-13 + */ + +var _ = require('sugo-sdk-js-utils')['default'] + +/** + * @param {string} value + * @param {boolean} optimized + * @constructor + */ +function DOMNodePathStep (value, optimized) { + this.value = value + this.optimized = optimized || false +} + +DOMNodePathStep.prototype.toString = function () { + return this.value +} + +/** + * 将string转为16进制计数 + * @param {string} c + * @return {string} + */ +function toHexByte (c) { + var hexByte = c.charCodeAt(0).toString(16) + if (hexByte.length === 1) { + hexByte = '0' + hexByte + } + return hexByte +} + +/** + * 验证是否为合法的css标识字符 + * 有效字符为[a-zA-Z0-9-] + * @param {string} c + * @return {boolean} + */ +function isCSSIdentChar (c) { + if (/[a-zA-Z0-9_-]/.test(c)) return true + return c.charCodeAt(0) >= 0xA0 +} + +/** + * css标识是否合法 + * 以-或[a-zA-Z-]开头,后接[z-zA-Z0-9-] + * @param {string} value + * @return {boolean} + */ +function isCSSIdentifier (value) { + return /^-?[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value) +} + +/** + * 转义单个css标识字符 + * @param {string} c + * @param {boolean} isLast + * @return {string} + */ +function escapeAsciiChar (c, isLast) { + return '\\' + toHexByte(c) + (isLast ? '' : ' ') +} + +/** + * 转义css标识 + * @param {string} ident + * @return {string} + */ +function escapeIdentifierIfNeeded (ident) { + if (isCSSIdentifier(ident)) return ident + var shouldEscapeFirst = /^(?:[0-9]|-[0-9-]?)/.test(ident) + var lastIndex = ident.length - 1 + return ident.replace(/./g, function (c, i) { + return ((shouldEscapeFirst && i === 0) || !isCSSIdentChar(c)) + ? escapeAsciiChar(c, i === lastIndex) + : c + }) +} + +/** + * 生成id标识符 + * @param {string} id + * @return {string} + */ +function idSelector (id) { + return '#' + escapeIdentifierIfNeeded(id) +} + +/** + * 获取节点class属性并转成数组,并加上$前缀 + * @param {Element} node + * @return {Array} + */ +function prefixedElementClassNames (node) { + var classAttribute = node.getAttribute('class') + if (!classAttribute) return [] + var arr = classAttribute.split(/\s+/g) + arr = _.filter(arr, Boolean) + return _.map(arr, function (name) {return '$' + name}) +} + +module.exports.DOMNodePathStep = DOMNodePathStep +module.exports.toHexByte = toHexByte +module.exports.isCSSIdentChar = isCSSIdentChar +module.exports.isCSSIdentifier = isCSSIdentifier +module.exports.escapeAsciiChar = escapeAsciiChar +module.exports.escapeIdentifierIfNeeded = escapeIdentifierIfNeeded +module.exports.idSelector = idSelector +module.exports.prefixedElementClassNames = prefixedElementClassNames diff --git a/src/css-path/index.bak.js b/src/css-path/index.bak.js new file mode 100644 index 0000000..7c44653 --- /dev/null +++ b/src/css-path/index.bak.js @@ -0,0 +1,218 @@ +/** + * @Author sugo.io + * @Date 17-9-13 + */ + +var base = require('./base') +var _ = require('sugo-sdk-js-utils')['default'] + +var SHADOW_NODE_CONS = { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12, + DOCUMENT_POSITION_DISCONNECTED: 1, + DOCUMENT_POSITION_PRECEDING: 2, + DOCUMENT_POSITION_FOLLOWING: 4, + DOCUMENT_POSITION_CONTAINS: 8, + DOCUMENT_POSITION_CONTAINED_BY: 16, + DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32 +} +var NodeConstructor = _.isBrowser() ? window.Node || SHADOW_NODE_CONS : SHADOW_NODE_CONS +var DOMNodePathStep = base.DOMNodePathStep + +/** + * 生成节点的选择器 + * @param {Element} node + * @param {boolean} optimized - 是否生成最优路径 + * @param {boolean} isTargetNode + * @return {?DOMNodePathStep} + */ +function selector (node, optimized, isTargetNode) { + + if (node.nodeType !== NodeConstructor.ELEMENT_NODE) { + return null + } + + var id = node.getAttribute('id') + if (optimized && id) { + return new DOMNodePathStep(node.nodeName.toLowerCase() + base.idSelector(id), true) + } + + // 如果该元素为Document节点,返回nodeName + var parent = node.parentNode + var nodeName = node.nodeName.toLowerCase() + + if (!parent || parent.nodeType === NodeConstructor.DOCUMENT_NODE) { + return new DOMNodePathStep(nodeName.toLowerCase(), true) + } + // 根据兄弟元素确定selector + var prefixedOwnClassNamesArray = base.prefixedElementClassNames(node) + var needsClassNames = false + var needsNthChild = false + var ownIndex = -1 + var siblings = parent.children + var i = 0 + + // 如果需要生成最优路径,遍历所有的兄弟元素,规则如下 + // 1. 若兄弟元素与节点名称相同,则需要用class来区分,标识needsClassNames=true + // 2. 如果节点没有class,则需要用元素位置来区分,标识needsNthChild=true + // 3. 如果节点有class,遍历兄弟元素的class,如果节点class与兄弟元素class重复 + // 依然需要元素位置来区分,标识needsNthChild=true + if (optimized) { + for (i = 0; (ownIndex === -1 || !needsNthChild) && i < siblings.length; ++i) { + var sibling = siblings[i] + if (sibling === node) { + ownIndex = i + continue + } + if (needsNthChild) { + continue + } + if (sibling.nodeName.toLowerCase() !== nodeName.toLowerCase()) { + continue + } + + needsClassNames = true + var ownClassNames = prefixedOwnClassNamesArray + var ownClassNameCount = 0 + for (var cn_idx = 0; cn_idx < ownClassNames.length; cn_idx++) { + ++ownClassNameCount + } + if (ownClassNameCount === 0) { + needsNthChild = true + continue + } + var siblingClassNamesArray = base.prefixedElementClassNames(sibling) + for (var j = 0; j < siblingClassNamesArray.length; ++j) { + var siblingClass = siblingClassNamesArray[j] + var o_idx = _.indexOf(ownClassNames, siblingClass) + if (o_idx === -1) { + continue + } + ownClassNames.splice(o_idx, 1) + if (!--ownClassNameCount) { + needsNthChild = true + break + } + } + } + } else if (siblings.length > 1) { + // 如果不需要生成最优路径,则全部使用nth-child描述 + for (i = 0; i < siblings.length; i++) { + if (siblings[i] === node) { + ownIndex = i + needsNthChild = true + break + } + } + } + + // 生成selector + // 1. 如果节点为input,并带有type属性、节点没有id、没有class,则需要标识节点的type类型 + // 2. 如果needsNthChild为true,则需要生成:nth-child + // 3. 如果需要class标识,则需要生成class + var result = nodeName.toLowerCase() + if (isTargetNode && nodeName.toLowerCase() === 'input' && + node.getAttribute('type') && + !node.getAttribute('id') && + !node.getAttribute('class')) { + result += '[type=\"' + node.getAttribute('type') + '\"]' + } + + if (needsNthChild) { + result += ':nth-child(' + (ownIndex + 1) + ')' + } else if (needsClassNames) { + for (var idx = 0; idx < ownClassNames.length; idx++) { + result += '.' + base.escapeIdentifierIfNeeded(ownClassNames[idx].substr(1)) + } + } + + return new DOMNodePathStep(result, false) +} + +/** + * 返回一个节点的 css selector + * @param {Element} node + * @param {boolean} optimized + * @param {boolean} isTargetNode + * @param {boolean} compatible 兼容原来的方式 + * @return {DOMNodePathStep} + */ +function step (node, optimized, isTargetNode, compatible) { + if (node.nodeType !== NodeConstructor.ELEMENT_NODE) { + return null + } + + var id = node.getAttribute('id') + // 最优解析 + // 如果该元素有id,直接返回id + + if (optimized && id && !compatible) { + return new DOMNodePathStep(base.idSelector(id), true) + } + + // 如果元素全局唯一,直接返回元素nodeName + var nodeNameLower = node.nodeName.toLowerCase() + if (nodeNameLower === 'body' || nodeNameLower === 'head' || nodeNameLower === 'html') { + return new DOMNodePathStep(node.nodeName.toLowerCase(), true) + } + + // 生成选择器 + return selector(node, optimized, isTargetNode) +} + +/** + * 生成节点 selector + * @param {Element} node + * @param {boolean} optimized - 是否生成最优selector + * @param {boolean} compatible + * @return {string} + */ +function path (node, optimized, compatible) { + if (node.nodeType !== NodeConstructor.ELEMENT_NODE) { + return '' + } + + var steps = [] + var contextNode = node + + while (contextNode) { + var token = step(contextNode, optimized, contextNode === node, compatible) + if (!token) break + steps.push(token) + if (token.optimized) break + contextNode = contextNode.parentNode + } + + return steps.reverse().join(' > ') +} + +/** + * 生成节点最优 selector + * @param node + * @return {string} + */ +function optimized (node) { + return path(node, true, true) +} + +/** + * 生成节点完整 selector + * @param {Element} node + * @return {string} + */ +function entire (node) { + return path(node, false, false) +} + +module.exports.optimized = optimized +module.exports.entire = entire diff --git a/src/css-path/index.js b/src/css-path/index.js new file mode 100644 index 0000000..7331257 --- /dev/null +++ b/src/css-path/index.js @@ -0,0 +1,5 @@ +/** + * @Author sugo.io + * @Date 17-11-28 + */ +module.exports = require('sugo-css-path') \ No newline at end of file diff --git a/src/debugger.js b/src/debugger.js new file mode 100644 index 0000000..8976533 --- /dev/null +++ b/src/debugger.js @@ -0,0 +1,76 @@ +/** + * @Author sugo.io + * @Date 17-10-30 + */ + +var _ = require('./utils')._ +var Regulation = require('./regulation-parser') +var Selector = require('./css-path') +var Logger = require('./logger').get('Sugoio.Debugger') + +/** + * @typedef {Object} SDKBuffer + * @property {Array} PageConfigs - 页面配置列表 + * @property {Array} PageEvents - 页面事件列表 + * @property {Array} PageCategories - 页面分类列表 + */ + +/** @type {SDKBuffer} */ +var Buffer = { + PageConfigs: [], + PageEvents: [], + PageCategories: [] +} + +var Debugger = { + /** + * css path apis + */ + Selector: Selector, + + /** + * 'PageConfigs'|'PageEvents'|'PageCategories' + * @param {string} key + * @param {*} value + * @return {exports} + */ + addBuffer: function (key, value) { + if (!Buffer.hasOwnProperty(key)) { + Logger.warn('Not allow key: [%s]', key) + return this + } + Buffer[key] = value + return this + }, + + /** + * @param {function} filter + * @return {EventDesc|null} + */ + findEvent: function (filter) { + return Buffer.PageEvents.find(filter) || null + }, + + /** + * @param {function} filter + * @return {PageConfigs|null} + */ + findPageConfig: function (filter) { + return Buffer.PageConfigs.find(filter) || null + }, + + /** + * @param {function} filter + * @return {PageCategories|null} + */ + findCategories: function (filter) { + return Buffer.PageCategories.find(filter) || null + }, + + // Regulation + Regulation: Regulation, + // utils + _: _ +} + +module.exports = Debugger diff --git a/src/global.js b/src/global.js new file mode 100644 index 0000000..826af19 --- /dev/null +++ b/src/global.js @@ -0,0 +1,100 @@ +/** + * @Author sugo.io + * @Date 17-10-28 + * @description sdk全局对像,挂载sdk所有数据 + */ + +/** + * 定义SDKGlobal对象上的属性 + * @typedef {Object} SDKGlobal + * @property {string} version + * @property {Logger} Logger + * + * @property {SDKEditor} Editor - editor + * + * @property {Debugger} Debugger + * @property {Regulations} Regulation + */ + +var CONSTANTS = require('../constants') +var logger = require('./logger') +var Debugger = require('./debugger') +var Regulation = require('./regulation-parser') + +var PRIMARY_INSTANCE_NAME = CONSTANTS.PRIMARY_INSTANCE_NAME +var INJECT_CONFIG_PROP_KEY = CONSTANTS.INJECT_CONFIG_PROP_KEY +var Store = {} + +/** + * @return {SugoIORawMaster|Sugoio} + */ +function get () { + return window[PRIMARY_INSTANCE_NAME] || (window[PRIMARY_INSTANCE_NAME] = []) +} + +/** + * @param ins + * @return {SugoIORawMaster|Sugoio} + */ +function set (ins) { + var prev = get() + ins.Global = Global + ins[INJECT_CONFIG_PROP_KEY] = prev[INJECT_CONFIG_PROP_KEY] + window[PRIMARY_INSTANCE_NAME] = ins + return ins +} + +/** + * 全局内存存储器 + * @param {string} key + * @param {*} value + * @return {*} + */ +function store (key, value) { + Store[key] = value + return value +} + +/** + * @param {string} key + * @return {*} + */ +function remove (key) { + var v + if (Store.hasOwnProperty(key)) { + v = Store[key] + delete Store[key] + } + return v +} + +/** + * @param {string} key + * @return {*} + */ +function take (key) { + return Store[key] +} + +var SDKGlobal = get() +/** @type {SDKGlobal} */ +var Global = SDKGlobal.Global || (SDKGlobal.Global = {}) + +Global.Logger = logger +Global.Debugger = Debugger +Global.version = process.env.SDK_VERSION +Global.Regulation = Regulation + +// initialize +set(get()) + +module.exports = { + Global: Global, + get: get, + set: set, + store: store, + remove: remove, + take: take +} + + diff --git a/src/images/cancel-x.png b/src/images/cancel-x.png new file mode 100644 index 0000000..d20b34b Binary files /dev/null and b/src/images/cancel-x.png differ diff --git a/src/images/close-x-dark.png b/src/images/close-x-dark.png new file mode 100644 index 0000000..abd5551 Binary files /dev/null and b/src/images/close-x-dark.png differ diff --git a/src/images/close-x-light.png b/src/images/close-x-light.png new file mode 100644 index 0000000..6b15e2b Binary files /dev/null and b/src/images/close-x-light.png differ diff --git a/src/images/mini-news-dark.png b/src/images/mini-news-dark.png new file mode 100644 index 0000000..82ae4a7 Binary files /dev/null and b/src/images/mini-news-dark.png differ diff --git a/src/images/play-dark-small.png b/src/images/play-dark-small.png new file mode 100644 index 0000000..86c27e2 Binary files /dev/null and b/src/images/play-dark-small.png differ diff --git a/src/loader-globals.js b/src/loader-globals.js new file mode 100644 index 0000000..cffbd49 --- /dev/null +++ b/src/loader-globals.js @@ -0,0 +1 @@ +require('./sugoio/entry').init_from_snippet() diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..5e7bc48 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,60 @@ +/** + * @Author sugo.io + * @Date 17-10-30 + */ + +/** @type {SugoIOSDKJSConfig} */ +var Conf = require('./config') +var Utils = require('sugo-sdk-js-utils')['default'] +var LoggerMap = {} +var DEFAULT_NAMESPACE = 'Sugoio.SDK' +// 开放一个debug隐藏参数,以便调试 +// 因为search以?开头,所以indexOf一定大于0 +var URLQuery = window.location.search.indexOf('sugoio_debug=true') > 0 + +module.exports = { + /** + * @param {string} [namespace] + * @return {Utils.Logger} + */ + get: function (namespace) { + namespace = namespace || DEFAULT_NAMESPACE + + if (LoggerMap.hasOwnProperty(namespace)) { + return LoggerMap[namespace] + } + + var logger = new (Utils.Logger)(namespace) + + // 如果开启了debug或者开发环境下,输出全部日志信息 + if (Conf.debug || URLQuery || process.env.NODE_ENV === 'development') { + logger.setLevel(Utils.LoggerLevel.DEBUG) + } else { + // 生产环境只输出error级别信息 + logger.setLevel(Utils.LoggerLevel.ERROR) + } + + LoggerMap[namespace] = logger + return logger + }, + + /** + * @param {Utils.LoggerLevel} level + * @param {string} [namespace] + */ + setLevel: function setLevel (level, namespace) { + if (namespace) { + if (LoggerMap[namespace]) { + LoggerMap[namespace].setLevel(level) + } + } + + for (var propKey in LoggerMap) { + LoggerMap[propKey].setLevel(level) + } + } +} + +// 创建默认Logger +module.exports.get(DEFAULT_NAMESPACE) + diff --git a/src/match-page.js b/src/match-page.js new file mode 100644 index 0000000..5bf4ae0 --- /dev/null +++ b/src/match-page.js @@ -0,0 +1,38 @@ +/** + * @Author sugo.io + * @Date 17-8-28 + * @description 确定页面信息 + * 如果当前页面匹配多条页面信息记录 + * 1. 设url = location.origin + location.pathname + * 2. 优先匹配page === url的记录,如果没有匹配到,则最最后一次更新的记录 + */ + +var _ = require('./utils')._ +var Logger = require('./logger').get() + +/** + * @param {String} url + * @param {Array} list + * @return {?PageConfDesc} + */ +module.exports = function (url, list) { + const conf = _.find(list, r => { + return r.page === url + }) + + Logger.info('Matched page config %o', conf) + return conf + // if (conf) { + // Logger.info('Matched page config %o', conf) + // return conf + // } + + // conf = list.sort(function (a, b) { + // return new Date(a.changed_on) - new Date(b.changed_on) + // }) + + // var result = conf[0] || null + // Logger.info('Matched page config %o', result) + // return result +} + diff --git a/src/match-pathname.js b/src/match-pathname.js new file mode 100644 index 0000000..5302c38 --- /dev/null +++ b/src/match-pathname.js @@ -0,0 +1,93 @@ +/** + * @Author sugo.io + * @Date 17-8-19 + * @description + * 改版后页面事件规则变得复杂,具体规则如下 + * 设: + * `url = location.origin + location.pathname` + * + * ## 页面配置信息变化 + * 1. `page` 存的值由`location.pathname`变为`url`。 + * 2. 新增`category`字段 + * + * ## 事件来源变化 + * 1. 查询`appid`下的所有`PageConfDesc`,得到集合`S(p)` + * 2. 建立集合`P`,使用`S(p)`的`category`去匹配`url`,匹配成功,则将记录`page`属性添加到`P`。 + * 兼容旧数据: + * 旧数据中没有`category`,使`category = '*' + page` + * 3. 使用页面`P`集合查询所有事件,匹配条件为`EventDesc.page in P` + * + * @see {PageConfDesc} + * @see {EventDesc} + * + */ + +var _ = require('./utils')._ +var API = require('./sugo-event-api') +var Regulation = require('./regulation-parser') +var Logger = require('./logger').get() + +/** + * @param {Array} list + * @param {string} url + * @param {string} path + * @return {Array} + */ +function matcher (list, url, path) { + + list = _.map(list, function (r) { + if (!r.category) { + r.category = '*' + r.page + } + return r + }) + + // 2.匹配page + // 兼容旧版数据: + // 旧的数据中没有category字段,默认使用'*' + page为其category + + // 获取与当前页面兼容的页面配置 + var regulations = Regulation.match(url, _.map(list, function (r) { + return r.category + })) + + // 事件来源: + // 1. url下的所有事件 + // 2. 页面category能匹配当前url的所有页面 + var path_name = [url, path] + + _.each(list, function (r) { + if (_.indexOf(regulations, r.category) !== -1) { + path_name.push(r.page) + } + }) + + return _.uniq(path_name) +} + +/** + * @param {String} host + * @param {String} token + * @param {String} url + * @param {String} path + * @param {boolean} deployed + * @param {Function} callback + */ +module.exports = function (host, token, url, path, deployed, callback) { + + // 配置 host + API.host = host + + // 查询 token 下的所有PageConfDesc + API.getAllPageInfo(token, '0', deployed).success(function (res) { + + if (!res.success) { + Logger.error('get all page info error: %s', res.message) + callback(res.message) + } + + callback(null, matcher(res.result || [], url, path), res.result) + }) +} + +module.exports.matcher = matcher diff --git a/src/regulation-parser.js b/src/regulation-parser.js new file mode 100644 index 0000000..ab3c0f4 --- /dev/null +++ b/src/regulation-parser.js @@ -0,0 +1,22 @@ +/** + * @Author sugo.io + * @Date 17-8-17 + * @description 通配符正则匹配 + */ + +var Regulation = require('css-selector-parser').Regulation + +/** + * @typedef {object} Regulations + * @property {function} test + * @property {function} creator + * @property {function} exec + * @property {function} match + */ + +module.exports = { + test: Regulation.test, + creator: Regulation.creator, + exec: Regulation.exec, + match: Regulation.match +} diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000..b26df52 --- /dev/null +++ b/src/request.js @@ -0,0 +1,150 @@ +/** + * @Author sugo.io + * @Date 17-11-14 + * @description 兼容跨域方案 + */ + +var _ = require('./utils')._ +var logger = require('./logger').get() + +var XMLHttpRequestCORS = typeof XMLHttpRequest !== void 0 && 'withCredentials' in new XMLHttpRequest() +// XDomainRequest only work in ie8+ +// https://hsivonen.fi/doctype/#ie8modes +// ie默认使用的是兼容模式,即ie7,所以在ie8与ie9下userAgent均为MSIE7 +var XDomainRequestCORS = typeof XDomainRequest !== void 0 && navigator.userAgent.indexOf('MSIE') > 0 && parseInt(navigator.userAgent.match(/MSIE ([\d.]+)/)[1], 10) >= 7 + +if (XMLHttpRequestCORS) { + logger.info('Support XMLHttpRequestCORS') +} + +if (XDomainRequestCORS) { + logger.info('Support XDomainRequestCORS') +} + +if (!XMLHttpRequestCORS && !XDomainRequestCORS) { + logger.warn('Not Support CORS. Sugoio will not work if your domain diff with config.api_host') +} + +function parse (resp) { + var result + try { + result = _.JSONDecode(resp) + } catch (e) { + result = resp + } + return result +} + +/** + * @typedef {Object} RequestHooksStruct + * @property {function(method:string, url:string, data:*): RequestHooksStruct} send + * @property {function(callback:function): RequestHooksStruct} success + * @property {function(callback:function): RequestHooksStruct} error + */ + +/** + * @return {RequestHooksStruct} + */ +function createXMLHttpRequestCORS () { + var xhr = new XMLHttpRequest() + var callbacks = { + success: null, + error: null + } + var hooks = { + send: function (method, url, data) { + xhr.open(method, url) + xhr.withCredentials = false + xhr.onreadystatechange = function () { + var res + if (xhr.readyState === 4) { + res = parse(xhr.responseText) + if (xhr.status >= 200 && xhr.status < 300) { + if (callbacks.success) { + callbacks.success(res) + } + } else { + if (callbacks.error) { + callbacks.error(res) + } + } + } + } + xhr.send(data || null) + return hooks + }, + success: function (callback) { + callbacks.success = callback + return hooks + }, + error: function (callback) { + callbacks.error = callback + return hooks + } + } + return hooks +} + +/** + * @return {RequestHooksStruct} + */ +function createXDomainRequestCORS () { + var xdr = new XDomainRequest() + var callbacks = { + success: null, + error: null + } + var hooks = { + send: function (method, url, data) { + xdr.open(method, url) + xdr.onload = function () { + if (callbacks.success) { + callbacks.success(parse(xdr.responseText)) + } + } + xdr.onerror = function (err) { + if (callbacks.error) { + callbacks.error(err) + } + } + xdr.ontimeout = function () {} + xdr.onprogress = function () {} + setTimeout(function () { + xdr.send(data || null) + }, 0) + return hooks + }, + success: function (callback) { + callbacks.success = callback + }, + error: function (callback) { + callbacks.error = callback + } + } + return hooks + +} + +/** @type {RequestHooksStruct} */ +var Fake = { + send: function () { return Fake }, + success: function () { return Fake }, + error: function () { return Fake } +} + +/** + * @return {RequestHooksStruct} + */ +module.exports = function () { + if (XMLHttpRequestCORS) { + return createXMLHttpRequestCORS() + } + + if (XDomainRequestCORS) { + return createXDomainRequestCORS() + } + + // not support CORS + return Fake +} + diff --git a/src/sugo-event-api.js b/src/sugo-event-api.js new file mode 100644 index 0000000..56b37fe --- /dev/null +++ b/src/sugo-event-api.js @@ -0,0 +1,132 @@ +var Request = require('./sugoio-request') +var _ = require('./utils')._ +var CONFIG = require('./config') +var Logger = require('./logger').get() +var request = require('./request') + +function concat () { + var arr = _.toArray(arguments) + return arr.join('') +} + +function set_host (host) { + return /^https?/.test(host) ? host : document.location.protocol + '//' + host +} + +module.exports = { + + // 如果app_host中没有配置协议,则默认使用当前页面的协议 + host: set_host(CONFIG.app_host), + + /** + * 更新host + * @param {string} host + */ + set_host: function (host) { + this.host = set_host(host) + }, + + serialize: function (params) { + Logger.debug('Before serialize params: %o', params) + return JSON.stringify({ q: _.compressUrlQuery(params) }) + }, + + queryString: function (params) { + Logger.debug('Before compress params: %o', params) + return 'q=' + _.compressUrlQuery(params) + }, + + list: function (params) { + return request().send('GET', concat(this.host, '/api/sdk/desktop/vtrack-events-draft?', this.queryString(params))) + }, + + create: function (params) { + return Request.post(concat(this.host, '/api/sdk/desktop/vtrack-events-draft/create'), this.serialize(params)) + }, + + delete: function (params) { + return Request['delete'](concat(this.host, '/api/sdk/desktop/vtrack-events-draft/delete?', this.queryString(params))) + }, + + //部署可视化配置(将草稿表记录发布到正式表) + deploy: function (params) { + return Request.post(concat(this.host, '/api/sdk/desktop/vtrack-events/deploy'), this.serialize(params)) + }, + // 保存页面参数设置 + savePageInfo: function (params) { + return Request.post(concat(this.host, '/api/sdk/desktop/save-page-info'), this.serialize(params)) + }, + + /** + * 项目项目所有维度 + * @param {String} token + * @return {Object} + */ + dimensions: function (token) { + return Request.post(concat(this.host, '/api/sdk/desktop/dimensions'), JSON.stringify({ token: token })) + }, + + /** + * 保存、更新页面分类记录 + * @param {Array} models + * @param {String} token + * @param {String} app_version + */ + savePageCategories: function (models, token, app_version) { + return Request.post(concat(this.host, '/api/sdk/desktop/page-categories/save'), JSON.stringify({ + models: models, + token: token, + app_version: app_version + })) + }, + + /** + * 获取所有页面分类 + * @param {String} appid + * @param {String} app_version + */ + getPageCategories: function (appid, app_version) { + return Request.get(concat(this.host, '/api/sdk/desktop/page-categories?', this.queryString({ appid: appid, app_version: app_version }))) + }, + + /** + * 获取已部署所有页面分类 + * @param {String} appid + * @param {String} app_version + */ + getDeployedPageCategories: function (appid, app_version) { + return request().send('GET', concat(this.host, '/api/sdk/desktop/page-categories-deployed?', this.queryString({ appid: appid, app_version: app_version }))) + }, + + /** + * 查询appid所属的所有页面信息记录 + * @param {string} appid + * @param {string} app_version + * @param {boolean} deployed - 是否获取已部署的页面分类 + */ + getAllPageInfo: function (appid, app_version, deployed) { + return request().send('GET', concat( + this.host, + '/api/sdk/desktop/page-info-list' + (deployed ? '-deployed?' : '?'), + this.queryString({ appid: appid, app_version: app_version })) + ) + }, + + /** + * @typedef {Object} GetFirstLoginTimeResponseStruct + * @property {boolean} success + * @property {{firstLoginTime:number, isFirstLogin: boolean}} result + * @property {string} message + * @property {number} code + * @property {string} type + */ + /** + * 查询用户登录状态 + * @param user_id + * @param token + * @return {RequestHooksStruct} + */ + getFirstLoginTime: function (user_id, token) { + return request().send('GET', concat(this.host, '/api/sdk/desktop/get-first-login-time?userId=', user_id + '&token=' + token)) + } +} diff --git a/src/sugoio-request.js b/src/sugoio-request.js new file mode 100644 index 0000000..f731465 --- /dev/null +++ b/src/sugoio-request.js @@ -0,0 +1,89 @@ +var _ = require('./utils')._ +var sgRequest = module.exports +var config = { + withCredentials: true, + contentType: 'application/json', + headers: [] +} + +var parse = function (req) { + var result + try { + result = JSON.parse(req.responseText) + } catch (e) { + result = req.responseText + } + return [result, req] +} + +var xhr = function (type, url, data) { + var methods = { + success: function () {}, + error: function () {}, + always: function () {} + } + var XHR = window.XMLHttpRequest || window.ActiveXObject + var request = new XHR('MSXML2.XMLHTTP.3.0') + request.open(type, url, true) + request.withCredentials = true + request.setRequestHeader('content-type', config.contentType) + if (config.headers.length > 0) { + _.each(config.headers, function (o) { + request.setRequestHeader(o.key, o.value) + }) + } + request.onreadystatechange = function () { + var req + if (request.readyState === 4) { + req = parse(request) + if (request.status >= 200 && request.status < 300) { + methods + .success + .apply(methods, req) + } else { + methods + .error + .apply(methods, req) + } + methods + .always + .apply(methods, req) + } + } + request.send(data) + var resXHR = { + success: function (callback) { + methods.success = callback + return resXHR + }, + error: function (callback) { + methods.error = callback + return resXHR + }, + always: function (callback) { + methods.always = callback + return resXHR + } + } + return resXHR +} +sgRequest.get = function (src) { + return xhr('GET', src) +} +sgRequest.put = function (url, data) { + return xhr('PUT', url, data) +} +sgRequest.post = function (url, data) { + return xhr('POST', url, data) +} +sgRequest['delete'] = function (url) { + return xhr('DELETE', url) +} +sgRequest.setContentType = function (value) { + config.contentType = value +} +sgRequest.setRequestHeader = function (key, value) { + config.headers.push({ key: key, value: value }) +} + + diff --git a/src/sugoio/DomTracker.js b/src/sugoio/DomTracker.js new file mode 100644 index 0000000..a9f5567 --- /dev/null +++ b/src/sugoio/DomTracker.js @@ -0,0 +1,96 @@ +/** + * @Author sugo.io + * @Date 17-11-17 + */ +var _ = require('sugo-sdk-js-utils')['default'] +var Logger = require('../logger').get('DOMTracker') + +/** + * @constructor + */ +function DomTracker () { + this.mp = null +} + +// interface +DomTracker.prototype.create_properties = function () {} +DomTracker.prototype.event_handler = function () {} +DomTracker.prototype.after_track_handler = function () {} + +DomTracker.prototype.init = function (sugoio_instance) { + this.mp = sugoio_instance + return this +} + +/** + * @param {Object|string} query + * @param {string} event_name + * @param {Object=} properties + * @param {function(...[*])=} user_callback + */ +DomTracker.prototype.track = function (query, event_name, properties, user_callback) { + var that = this + var elements = _.querySelectorAll(query) + + if (elements.length === 0) { + Logger.error('The DOM query (' + query + ') returned 0 elements') + return + } + _.each(elements, function (element) { + _.register_event(element, this.override_event, function (e) { + var options = {} + var props = that.create_properties(properties, this) + var timeout = that.mp.get_config('track_links_timeout') + + that.event_handler(e, this, options) + + // in case the sugoio servers don't get back to us in time + window.setTimeout(that.track_callback(user_callback, props, options, true), timeout) + + // fire the tracking event + that.mp.track(event_name, props, that.track_callback(user_callback, props, options)) + }) + }, this) + + return true +} + +/** + * @param {function(...[*])} user_callback + * @param {Object} props + * @param {object} options + * @param {boolean} [timeout_occured] + */ +DomTracker.prototype.track_callback = function (user_callback, props, options, timeout_occured) { + timeout_occured = timeout_occured || false + var that = this + + return function () { + // options is referenced from both callbacks, so we can have + // a 'lock' of sorts to ensure only one fires + if (options.callback_fired) { return } + options.callback_fired = true + + if (user_callback && user_callback(timeout_occured, props) === false) { + // user can prevent the default functionality by + // returning false from their callback + return + } + + that.after_track_handler(props, options, timeout_occured) + } +} + +DomTracker.prototype.create_properties = function (properties, element) { + var props + + if (typeof(properties) === 'function') { + props = properties(element) + } else { + props = _.extend({}, properties) + } + + return props +} + +module.exports = DomTracker \ No newline at end of file diff --git a/src/sugoio/FormTracker.js b/src/sugoio/FormTracker.js new file mode 100644 index 0000000..30b1a96 --- /dev/null +++ b/src/sugoio/FormTracker.js @@ -0,0 +1,30 @@ +/** + * @Author sugo.io + * @Date 17-11-17 + */ + +var DomTracker = require('./DomTracker') +var _ = require('sugo-sdk-js-utils')['default'] + +/** + * FormTracker Object + * @varructor + * @extends DomTracker + */ +var FormTracker = function () { + this.override_event = 'submit' +} +_.inherit(FormTracker, DomTracker) + +FormTracker.prototype.event_handler = function (evt, element, options) { + options.element = element + evt.preventDefault() +} + +FormTracker.prototype.after_track_handler = function (props, options) { + setTimeout(function () { + options.element.submit() + }, 0) +} + +module.exports = FormTracker \ No newline at end of file diff --git a/src/sugoio/LinkTracker.js b/src/sugoio/LinkTracker.js new file mode 100644 index 0000000..60aee27 --- /dev/null +++ b/src/sugoio/LinkTracker.js @@ -0,0 +1,51 @@ +/** + * @Author sugo.io + * @Date 17-11-17 + */ + +var DomTracker = require('./DomTracker') +var _ = require('sugo-sdk-js-utils')['default'] + + +/** + * LinkTracker Object + * @varructor + * @extends DomTracker + */ +function LinkTracker () { + this.override_event = 'click' +} + +_.inherit(LinkTracker, DomTracker) + +LinkTracker.prototype.create_properties = function (properties, element) { + var props = LinkTracker.superclass.create_properties.apply(this, arguments) + + if (element.href) { props.url = element.href } + + return props +} + +LinkTracker.prototype.event_handler = function (evt, element, options) { + options.new_tab = ( + evt.which === 2 || + evt.metaKey || + evt.ctrlKey || + element.target === '_blank' + ) + options.href = element.href + + if (!options.new_tab) { + evt.preventDefault() + } +} + +LinkTracker.prototype.after_track_handler = function (props, options) { + if (options.new_tab) { return } + + setTimeout(function () { + window.location = options.href + }, 0) +} + +module.exports = LinkTracker \ No newline at end of file diff --git a/src/sugoio/People.js b/src/sugoio/People.js new file mode 100644 index 0000000..a62c22a --- /dev/null +++ b/src/sugoio/People.js @@ -0,0 +1,495 @@ +/** + * @Author sugo.io + * @Date 17-11-18 + */ + +var _ = require('sugo-sdk-js-utils')['default'] +var CONSTANTS = require('../../constants') +var Logger = require('../logger').get('People') + +function People () { + this._sugoio = null +} + +People.prototype._init = function (sugoio_instance) { + this._sugoio = sugoio_instance +} + +/** + * Set properties on a user record. + * + * ### Usage: + * + * sugoio.people.set('gender', 'm'); + * + * // or set multiple properties at once + * sugoio.people.set({ + * 'Company': 'Acme', + * 'Plan': 'Premium', + * 'Upgrade date': new Date() + * }); + * // properties can be strings, integers, dates, or lists + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ +People.prototype.set = function (prop, to, callback) { + var data = {} + var $set = {} + if (_.isObject(prop)) { + _.each(prop, function (v, k) { + if (!this._is_reserved_property(k)) { + $set[k] = v + } + }, this) + callback = to + } else { + $set[prop] = to + } + + // make sure that the referrer info has been updated and saved + if (this._get_config('save_referrer')) { + this._sugoio.persistence.update_referrer_info(document.referrer) + } + + // update $set object with default people properties + $set = _.extend( + {}, + _.info.people_properties(), + this._sugoio.persistence.get_referrer_info(), + $set + ) + + data[CONSTANTS.SET_ACTION] = $set + + return this._send_request(data, callback) +} + +/** + * Set properties on a user record, only if they do not yet exist. + * This will not overwrite previous people property values, unlike + * people.set(). + * + * ### Usage: + * + * sugoio.people.set_once('First Login Date', new Date()); + * + * // or set multiple properties at once + * sugoio.people.set_once({ + * 'First Login Date': new Date(), + * 'Starting Plan': 'Premium' + * }); + * + * // properties can be strings, integers or dates + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ +People.prototype.set_once = function (prop, to, callback) { + var data = {} + var $set_once = {} + if (_.isObject(prop)) { + _.each(prop, function (v, k) { + if (!this._is_reserved_property(k)) { + $set_once[k] = v + } + }, this) + callback = to + } else { + $set_once[prop] = to + } + data[CONSTANTS.SET_ONCE_ACTION] = $set_once + return this._send_request(data, callback) +} + +/** + * Increment/decrement numeric people analytics properties. + * + * ### Usage: + * + * sugoio.people.increment('page_views', 1); + * + * // or, for convenience, if you're just incrementing a counter by + * // 1, you can simply do + * sugoio.people.increment('page_views'); + * + * // to decrement a counter, pass a negative number + * sugoio.people.increment('credits_left', -1); + * + * // like sugoio.people.set(), you can increment multiple + * // properties at once: + * sugoio.people.increment({ + * counter1: 1, + * counter2: 6 + * }); + * + * @param {object|string} prop If a string, this is the name of the property. If an object, this is an associative array of names and numeric values. + * @param {number|function} [by] An amount to increment the given property + * @param {function} [callback] If provided, the callback will be called after the tracking event + */ +People.prototype.increment = function (prop, by, callback) { + var data = {} + var $add = {} + if (_.isObject(prop)) { + _.each(prop, function (v, k) { + if (!this._is_reserved_property(k)) { + if (isNaN(parseFloat(v))) { + return Logger.error('Invalid increment value passed to sugoio.people.increment - must be a number') + } else { + $add[k] = v + } + } + }, this) + callback = by + } else { + // convenience: sugoio.people.increment('property'); will + // increment 'property' by 1 + if (_.isUndefined(by)) { + by = 1 + } + $add[prop] = by + } + data[CONSTANTS.ADD_ACTION] = $add + + return this._send_request(data, callback) +} + +/** + * Append a value to a list-valued people analytics property. + * + * ### Usage: + * + * // append a value to a list, creating it if needed + * sugoio.people.append('pages_visited', 'homepage'); + * + * // like sugoio.people.set(), you can append multiple + * // properties at once: + * sugoio.people.append({ + * list1: 'bob', + * list2: 123 + * }); + * + * @param {Object|String} list_name If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [value] An item to append to the list + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ +People.prototype.append = function (list_name, value, callback) { + var data = {} + var $append = {} + if (_.isObject(list_name)) { + _.each(list_name, function (v, k) { + if (!this._is_reserved_property(k)) { + $append[k] = v + } + }, this) + callback = value + } else { + $append[list_name] = value + } + data[CONSTANTS.APPEND_ACTION] = $append + + return this._send_request(data, callback) +} + +/** + * Merge a given list with a list-valued people analytics property, + * excluding duplicate values. + * + * ### Usage: + * + * // merge a value to a list, creating it if needed + * sugoio.people.union('pages_visited', 'homepage'); + * + * // like sugoio.people.set(), you can append multiple + * // properties at once: + * sugoio.people.union({ + * list1: 'bob', + * list2: 123 + * }); + * + * // like sugoio.people.append(), you can append multiple + * // values to the same list: + * sugoio.people.union({ + * list1: ['bob', 'billy'] + * }); + * + * @param {object|string} list_name If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [values] Value / values to merge with the given property + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ +People.prototype.union = function (list_name, values, callback) { + var data = {} + var $union = {} + if (_.isObject(list_name)) { + _.each(list_name, function (v, k) { + if (!this._is_reserved_property(k)) { + $union[k] = _.isArray(v) ? v : [v] + } + }, this) + callback = values + } else { + $union[list_name] = _.isArray(values) ? values : [values] + } + data[CONSTANTS.UNION_ACTION] = $union + + return this._send_request(data, callback) +} + +/** + * Record that you have charged the current user a certain amount + * of money. Charges recorded with track_charge() will appear in the + * Sugoio revenue report. + * + * ### Usage: + * + * // charge a user $50 + * sugoio.people.track_charge(50); + * + * // charge a user $30.50 on the 2nd of january + * sugoio.people.track_charge(30.50, { + * 'time': new Date('jan 1 2012') + * }); + * + * @param {Number} amount The amount of money charged to the current user + * @param {Object} [properties] An associative array of properties associated with the charge + * @param {Function} [callback] If provided, the callback will be called when the server responds + */ +People.prototype.track_charge = function (amount, properties, callback) { + if (!_.isNumber(amount)) { + amount = parseFloat(amount) + if (isNaN(amount)) { + Logger.error('Invalid value passed to sugoio.people.track_charge - must be a number') + return + } + } + + return this.append('transactions', _.extend({ 'amount': amount }, properties), callback) +} + +/** + * Permanently clear all revenue report transactions from the + * current user's people analytics profile. + * + * ### Usage: + * + * sugoio.people.clear_charges(); + * + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ +People.prototype.clear_charges = function (callback) { + return this.set('transactions', [], callback) +} + +/** + * Permanently deletes the current people analytics profile from + * Sugoio (using the current client_distinct_id). + * + * ### Usage: + * + * // remove the all data you have stored about the current user + * sugoio.people.delete_user(); + * + */ +People.prototype.delete_user = function () { + if (!this._identify_called()) { + Logger.error('sugoio.people.delete_user() requires you to call identify() first') + return + } + var data = {} + data['delete'] = this._sugoio.get_distinct_id() + return this._send_request(data) +} + +/** + * toString + * @return {string} + */ +People.prototype.toString = function () { + return this._sugoio.toString() + '.people' +} + +/** + * @param {object} data + * @param {function} callback + * @return {object} + * @private + */ +People.prototype._send_request = function (data, callback) { + data.token = this._get_config('token') + data.distinct_id = this._sugoio.get_distinct_id() + + var date_encoded_data = _.encodeDates(data) + var truncated_data = _.truncate(date_encoded_data, 255) + var json_data = _.JSONEncode(date_encoded_data) + var encoded_data = _.base64Encode(json_data) + + if (!this._identify_called()) { + this._enqueue(data) + if (!_.isUndefined(callback)) { + if (this._get_config('verbose')) { + callback({ status: -1, error: null }) + } else { + callback(-1) + } + } + return truncated_data + } + + Logger.log('SUGOIO PEOPLE REQUEST:') + Logger.log(truncated_data) + + this._sugoio._send_request( + this._get_config('api_host') + '/post', + { + 'data': true, + 'locate': this.get_config('project_id'), + 'token': this.get_config('token') + }, + this._sugoio._prepare_callback(callback, truncated_data), + encoded_data + ) + + return truncated_data +} + +/** + * @param conf_var + * @return {*} + * @private + */ +People.prototype._get_config = function (conf_var) { + return this._sugoio.get_config(conf_var) +} + +/** + * @return {boolean} + * @private + */ +People.prototype._identify_called = function () { + return this._sugoio._flags.identify_called === true +} + +/** + * Queue up engage operations if identify hasn't been called yet. + * @param {object} data + * @private + */ +People.prototype._enqueue = function (data) { + if (CONSTANTS.SET_ACTION in data) { + this._sugoio.persistence._add_to_people_queue(CONSTANTS.SET_ACTION, data) + } else if (CONSTANTS.SET_ONCE_ACTION in data) { + this._sugoio.persistence._add_to_people_queue(CONSTANTS.SET_ONCE_ACTION, data) + } else if (CONSTANTS.ADD_ACTION in data) { + this._sugoio.persistence._add_to_people_queue(CONSTANTS.ADD_ACTION, data) + } else if (CONSTANTS.APPEND_ACTION in data) { + this._sugoio.persistence._add_to_people_queue(CONSTANTS.APPEND_ACTION, data) + } else if (CONSTANTS.UNION_ACTION in data) { + this._sugoio.persistence._add_to_people_queue(CONSTANTS.UNION_ACTION, data) + } else { + Logger.error('Invalid call to _enqueue():', data) + } +} + +/** + * Flush queued engage operations - order does not matter, + * and there are network level race conditions anyway + * @param _set_callback + * @param _add_callback + * @param _append_callback + * @param _set_once_callback + * @param _union_callback + * @private + */ +People.prototype._flush = function (_set_callback, _add_callback, _append_callback, _set_once_callback, _union_callback) { + var _this = this + var $set_queue = _.extend({}, this._sugoio.persistence._get_queue(CONSTANTS.SET_ACTION)) + var $set_once_queue = _.extend({}, this._sugoio.persistence._get_queue(CONSTANTS.SET_ONCE_ACTION)) + var $add_queue = _.extend({}, this._sugoio.persistence._get_queue(CONSTANTS.ADD_ACTION)) + var $append_queue = this._sugoio.persistence._get_queue(CONSTANTS.APPEND_ACTION) + var $union_queue = _.extend({}, this._sugoio.persistence._get_queue(CONSTANTS.UNION_ACTION)) + + if (!_.isUndefined($set_queue) && _.isObject($set_queue) && !_.isEmptyObject($set_queue)) { + _this._sugoio.persistence._pop_from_people_queue(CONSTANTS.SET_ACTION, $set_queue) + this.set($set_queue, function (response, data) { + // on bad response, we want to add it back to the queue + if (response === 0) { + _this._sugoio.persistence._add_to_people_queue(CONSTANTS.SET_ACTION, $set_queue) + } + if (!_.isUndefined(_set_callback)) { + _set_callback(response, data) + } + }) + } + + if (!_.isUndefined($set_once_queue) && _.isObject($set_once_queue) && !_.isEmptyObject($set_once_queue)) { + _this._sugoio.persistence._pop_from_people_queue(CONSTANTS.SET_ONCE_ACTION, $set_once_queue) + this.set_once($set_once_queue, function (response, data) { + // on bad response, we want to add it back to the queue + if (response === 0) { + _this._sugoio.persistence._add_to_people_queue(CONSTANTS.SET_ONCE_ACTION, $set_once_queue) + } + if (!_.isUndefined(_set_once_callback)) { + _set_once_callback(response, data) + } + }) + } + + if (!_.isUndefined($add_queue) && _.isObject($add_queue) && !_.isEmptyObject($add_queue)) { + _this._sugoio.persistence._pop_from_people_queue(CONSTANTS.ADD_ACTION, $add_queue) + this.increment($add_queue, function (response, data) { + // on bad response, we want to add it back to the queue + if (response === 0) { + _this._sugoio.persistence._add_to_people_queue(CONSTANTS.ADD_ACTION, $add_queue) + } + if (!_.isUndefined(_add_callback)) { + _add_callback(response, data) + } + }) + } + + if (!_.isUndefined($union_queue) && _.isObject($union_queue) && !_.isEmptyObject($union_queue)) { + _this._sugoio.persistence._pop_from_people_queue(CONSTANTS.UNION_ACTION, $union_queue) + this.union($union_queue, function (response, data) { + // on bad response, we want to add it back to the queue + if (response === 0) { + _this._sugoio.persistence._add_to_people_queue(CONSTANTS.UNION_ACTION, $union_queue) + } + if (!_.isUndefined(_union_callback)) { + _union_callback(response, data) + } + }) + } + + // we have to fire off each $append individually since there is + // no concat method server side + if (!_.isUndefined($append_queue) && _.isArray($append_queue) && $append_queue.length) { + var $append_item + var callback = function (response, data) { + if (response === 0) { + _this._sugoio.persistence._add_to_people_queue(CONSTANTS.APPEND_ACTION, $append_item) + } + if (!_.isUndefined(_append_callback)) { + _append_callback(response, data) + } + } + for (var i = $append_queue.length - 1; i >= 0; i--) { + $append_item = $append_queue.pop() + _this.append($append_item, callback) + } + // Save the shortened append queue + _this._sugoio.persistence.save() + } +} + +/** + * @param {*} prop + * @return {boolean} + * @private + */ +People.prototype._is_reserved_property = function (prop) { + return prop === 'distinct_id' || prop === 'token' +} + +module.exports = People diff --git a/src/sugoio/Persistence.js b/src/sugoio/Persistence.js new file mode 100644 index 0000000..b33d654 --- /dev/null +++ b/src/sugoio/Persistence.js @@ -0,0 +1,536 @@ +/** + * @Author sugo.io + * @Date 17-11-17 + */ + +var _ = require('sugo-sdk-js-utils')['default'] +var Config = require('../config') +var CONSTANTS = require('../../constants') +var Logger = require('../logger').get('Persistence') + +var RESERVED_PROPERTIES = [ + CONSTANTS.SET_QUEUE_KEY, + CONSTANTS.SET_ONCE_QUEUE_KEY, + CONSTANTS.ADD_QUEUE_KEY, + CONSTANTS.APPEND_QUEUE_KEY, + CONSTANTS.UNION_QUEUE_KEY, + CONSTANTS.PEOPLE_DISTINCT_ID_KEY, + CONSTANTS.ALIAS_ID_KEY, + CONSTANTS.CAMPAIGN_IDS_KEY, + CONSTANTS.EVENT_TIMERS_KEY +] + +/** + * 初始化时确定storage + * Sugoio Persistence Object + * @param {SugoIOConfigInterface} config + */ +function Persistence (config) { + this.props = {} + this.name = null + this.storage = null + this.disabled = false + this.campaign_params_saved = false + this.default_expiry = null + this.expire_days = null + this.cross_subdomain = false + this.secure = false + + if (config.persistence_name) { + this.name = 'sg_' + config.persistence_name + } else { + this.name = 'sg_' + config.token + '_sugoio' + } + + var storage_type = config.persistence + if (storage_type !== 'cookie' && storage_type !== 'localStorage') { + Logger.error('Unknown persistence type ' + storage_type + '; falling back to cookie') + storage_type = config.persistence = 'cookie' + } + + function localStorage_supported () { + var supported = true + try { + var key = '__sglssupport__', val = 'xyz' + _.localStorage.set(key, val) + if (_.localStorage.get(key) !== val) { + supported = false + } + _.localStorage.remove(key) + } catch (err) { + supported = false + } + if (!supported) { + Logger.warn('localStorage unsupported; falling back to cookie store') + } + return supported + } + + if (storage_type === 'localStorage' && localStorage_supported()) { + this.storage = _.localStorage + } else { + this.storage = _.cookie + } + + this.load() + this.update_config(config) + this.upgrade(config) + this.save() +} + +/** + * 返回props上的所有字段,排除 RESERVED_PROPERTIES 中的 key + * @return {object} + */ +Persistence.prototype.properties = function () { + var p = {} + // Filter out reserved properties + _.each(this.props, function (v, k) { + if (_.include(RESERVED_PROPERTIES, k) || k.indexOf(CONSTANTS.PEOPLE_REAL_USER_ID_DIMENSION_PREFIX) === 0) { + return + } + p[k] = v + }) + return p +} + +/** + * 读取已存的数据 + * @return {object} + */ +Persistence.prototype.load = function () { + if (this.disabled) { return this.props } + + var entry = this.storage.parse(this.name) + + if (entry) { + this.props = _.extend({}, entry) + } + + return this.props +} + +/** + * 更新属性 + * @param {SugoIOConfigInterface} config + * @return {Persistence} + */ +Persistence.prototype.upgrade = function (config) { + var upgrade_from_old_lib = config.upgrade, old_cookie_name, old_cookie + + if (upgrade_from_old_lib) { + old_cookie_name = 'sg_super_properties' + // Case where they had a custom cookie name before. + if (typeof(upgrade_from_old_lib) === 'string') { + old_cookie_name = upgrade_from_old_lib + } + + old_cookie = this.storage.parse(old_cookie_name) + + // remove the cookie + this.storage.remove(old_cookie_name) + this.storage.remove(old_cookie_name, true) + + if (old_cookie) { + this.props = _.extend( + this.props, + old_cookie.all, + old_cookie.events + ) + } + } + + if (!config.cookie_name && config.name !== CONSTANTS.PRIMARY_INSTANCE_NAME) { + // special case to handle people with cookies of the form + // mp_TOKEN_INSTANCENAME from the first release of this library + old_cookie_name = 'sg_' + config.token + '_' + config.name + old_cookie = this.storage.parse(old_cookie_name) + + if (old_cookie) { + this.storage.remove(old_cookie_name) + this.storage.remove(old_cookie_name, true) + + // Save the prop values that were in the cookie from before - + // this should only happen once as we delete the old one. + this.register_once(old_cookie) + } + } + + if (this.storage === _.localStorage) { + old_cookie = _.cookie.parse(this.name) + + _.cookie.remove(this.name) + _.cookie.remove(this.name, true) + + if (old_cookie) { + this.register_once(old_cookie) + } + } + + return this +} + +/** + * 将属性写入到持久化存储中 + * @return {Persistence} + */ +Persistence.prototype.save = function () { + if (this.disabled) { return this} + this._expire_notification_campaigns() + this.storage.set( + this.name, + _.JSONEncode(this.props), + this.expire_days, + this.cross_subdomain, + this.secure + ) + return this +} + +/** + * 删除存储中的记录 + * @return {Persistence} + */ +Persistence.prototype.remove = function () { + // remove both domain and subdomain cookies + this.storage.remove(this.name, false) + this.storage.remove(this.name, true) + return this +} + +// removes the storage entry and deletes all loaded data +// forced name for tests +/** + * 删除存储中的记录,并清除props对象上的记录 + * @return {Persistence} + */ +Persistence.prototype.clear = function () { + this.remove() + this.props = {} + return this +} + +/** + * @param {Object} props + * @param {*=} default_value + * @param {number=} days + */ +Persistence.prototype.register_once = function (props, default_value, days) { + if (_.isObject(props)) { + if (typeof(default_value) === 'undefined') { + default_value = 'None' + } + + this.expire_days = (typeof(days) === 'undefined') ? this.default_expiry : days + + _.each(props, function (val, prop) { + if (!this.props[prop] || this.props[prop] === default_value) { + this.props[prop] = val + } + }, this) + + this.save() + + return true + } + return false +} + +/** + * @param {Object} props + * @param {number} [days] + * @return {boolean} + */ +Persistence.prototype.register = function (props, days) { + if (_.isObject(props)) { + this.expire_days = (typeof(days) === 'undefined') ? this.default_expiry : days + + _.extend(this.props, props) + + this.save() + + return true + } + return false +} + +/** + * 清除单个记录 + * @param prop + * @return {Persistence} + */ +Persistence.prototype.unregister = function (prop) { + if (prop in this.props) { + delete this.props[prop] + this.save() + } + return this +} + +/** + * 检测属性是否过期,如果过期,删除记录 + * @return {Persistence} + * @private + */ +Persistence.prototype._expire_notification_campaigns = function () { + var campaigns_shown = this.props[CONSTANTS.CAMPAIGN_IDS_KEY] + // 1 minute (Config.DEBUG) / 1 hour (PDXN) + var EXPIRY_TIME = Config.debug ? 60 * 1000 : 60 * 60 * 1000 + + if (!campaigns_shown) { + return this + } + + for (var campaign_id in campaigns_shown) { + if (!campaigns_shown.hasOwnProperty(campaign_id)) continue + if (1 * new Date() - campaigns_shown[campaign_id] > EXPIRY_TIME) { + delete campaigns_shown[campaign_id] + } + } + + if (_.isEmptyObject(campaigns_shown)) { + delete this.props[CONSTANTS.CAMPAIGN_IDS_KEY] + } + return this +} + +/** + * 写入有过期状态的属性 + * TODO 该属性值由_.info.campaignParams函数提供,真是太TM扯淡了,业务和库高度耦合 + * @return {Persistence} + */ +Persistence.prototype.update_campaign_params = function () { + if (!this.campaign_params_saved) { + this.register_once(_.info.campaignParams()) + this.campaign_params_saved = true + } + return this +} + +/** + * 解析页面referrer是否为搜索引擎,写入持久存储 + * @param referrer + * @return {Persistence} + */ +Persistence.prototype.update_search_keyword = function (referrer) { + this.register(_.info.searchInfo(referrer)) + return this +} + +// EXPORTED METHOD, we test this directly. +Persistence.prototype.update_referrer_info = function (referrer) { + // If referrer doesn't exist, we want to note the fact that it was type-in traffic. + this.register_once({ 'referring_domain': _.info.referringDomain(referrer) || 'direct' }, '') +} + +/** + * 解析referrer domain + * @return {object} + */ +Persistence.prototype.get_referrer_info = function () { + return _.strip_empty_properties({ 'referring_domain': this.props.referring_domain }) +} + +// safely fills the passed in object with stored properties, +// does not override any properties defined in both +// returns the passed in object +/** + * 合并this.props与props并返回结果 + * 只有props上没有的属性,才会从this.props取 + * @param {object} props + * @return {object} + */ +Persistence.prototype.safe_merge = function (props) { + _.each(this.props, function (val, prop) { + if (!(prop in props)) { + props[prop] = val + } + }) + + return props +} + +/** + * 设置属性 + * @param config + * @return {Persistence} + */ +Persistence.prototype.update_config = function (config) { + this.default_expiry = this.expire_days = config.cookie_expiration + this.set_disabled(config.disable_persistence) + this.set_cross_subdomain(config.cross_subdomain_cookie) + this.set_secure(config.secure_cookie) + return this +} + +/** + * 更新disabled属性,如果disable为true,删除记录 + * @param {boolean} disabled + * @return {Persistence} + */ +Persistence.prototype.set_disabled = function (disabled) { + this.disabled = disabled + if (this.disabled) { + this.remove() + } + return this +} + +/** + * 更新cross_subdomain,如果cross_subdomain与当前保存的不一致,则更新记录 + * @param cross_subdomain + * @return {Persistence} + */ +Persistence.prototype.set_cross_subdomain = function (cross_subdomain) { + if (cross_subdomain !== this.cross_subdomain) { + this.cross_subdomain = cross_subdomain + this.remove() + this.save() + } + return this +} + +/** + * 返回cross_subdomain + * @return {boolean} + */ +Persistence.prototype.get_cross_subdomain = function () { + return this.cross_subdomain +} + +/** + * 更新secure,如果与之前的记录不同,更新记录 + * @param {boolean} secure + * @return {Persistence} + */ +Persistence.prototype.set_secure = function (secure) { + if (secure !== this.secure) { + this.secure = !!secure + this.remove() + this.save() + } +} + +/** + * TODO 一堆people相关的操作,目前好像没用上,待查 + * @param queue + * @param data + * @private + */ +Persistence.prototype._add_to_people_queue = function (queue, data) { + var + q_key = this._get_queue_key(queue), + q_data = data[queue], + set_q = this._get_or_create_queue(CONSTANTS.SET_ACTION), + set_once_q = this._get_or_create_queue(CONSTANTS.SET_ONCE_ACTION), + add_q = this._get_or_create_queue(CONSTANTS.ADD_ACTION), + union_q = this._get_or_create_queue(CONSTANTS.UNION_ACTION), + append_q = this._get_or_create_queue(CONSTANTS.APPEND_ACTION, []) + + if (q_key === CONSTANTS.SET_QUEUE_KEY) { + // Update the set queue - we can override any existing values + _.extend(set_q, q_data) + // if there was a pending increment, override it + // with the set. + this._pop_from_people_queue(CONSTANTS.ADD_ACTION, q_data) + // if there was a pending union, override it + // with the set. + this._pop_from_people_queue(CONSTANTS.UNION_ACTION, q_data) + } else if (q_key === CONSTANTS.SET_ONCE_QUEUE_KEY) { + // only queue the data if there is not already a set_once call for it. + _.each(q_data, function (v, k) { + if (!(k in set_once_q)) { + set_once_q[k] = v + } + }) + } else if (q_key === CONSTANTS.ADD_QUEUE_KEY) { + _.each(q_data, function (v, k) { + // If it exists in the set queue, increment + // the value + if (k in set_q) { + set_q[k] += v + } else { + // If it doesn't exist, update the add + // queue + if (!(k in add_q)) { + add_q[k] = 0 + } + add_q[k] += v + } + }, this) + } else if (q_key === CONSTANTS.UNION_QUEUE_KEY) { + _.each(q_data, function (v, k) { + if (_.isArray(v)) { + if (!(k in union_q)) { + union_q[k] = [] + } + // We may send duplicates, the server will dedup them. + union_q[k] = union_q[k].concat(v) + } + }) + } else if (q_key === CONSTANTS.APPEND_QUEUE_KEY) { + append_q.push(q_data) + } + + Logger.log('SUGOIO PEOPLE REQUEST (QUEUED, PENDING IDENTIFY):') + Logger.log(data) + + this.save() +} + +Persistence.prototype._pop_from_people_queue = function (queue, data) { + var q = this._get_queue(queue) + if (!_.isUndefined(q)) { + _.each(data, function (v, k) { + delete q[k] + }, this) + + this.save() + } +} + +Persistence.prototype._get_queue_key = function (queue) { + if (queue === CONSTANTS.SET_ACTION) { + return CONSTANTS.SET_QUEUE_KEY + } else if (queue === CONSTANTS.SET_ONCE_ACTION) { + return CONSTANTS.SET_ONCE_QUEUE_KEY + } else if (queue === CONSTANTS.ADD_ACTION) { + return CONSTANTS.ADD_QUEUE_KEY + } else if (queue === CONSTANTS.APPEND_ACTION) { + return CONSTANTS.APPEND_QUEUE_KEY + } else if (queue === CONSTANTS.UNION_ACTION) { + return CONSTANTS.UNION_QUEUE_KEY + } else { + Logger.error('Invalid queue:', queue) + } +} + +Persistence.prototype._get_queue = function (queue) { + return this.props[this._get_queue_key(queue)] +} + +Persistence.prototype._get_or_create_queue = function (queue, default_val) { + var key = this._get_queue_key(queue) + default_val = _.isUndefined(default_val) ? {} : default_val + + return this.props[key] || (this.props[key] = default_val) +} + +Persistence.prototype.set_event_timer = function (event_name, timestamp) { + var timers = this.props[CONSTANTS.EVENT_TIMERS_KEY] || {} + timers[event_name] = timestamp + this.props[CONSTANTS.EVENT_TIMERS_KEY] = timers + this.save() +} + +Persistence.prototype.remove_event_timer = function (event_name) { + var timers = this.props[CONSTANTS.EVENT_TIMERS_KEY] || {} + var timestamp = timers[event_name] + if (!_.isUndefined(timestamp)) { + delete this.props[CONSTANTS.EVENT_TIMERS_KEY][event_name] + this.save() + } + return timestamp +} + +module.exports = Persistence diff --git a/src/sugoio/Sugoio.js b/src/sugoio/Sugoio.js new file mode 100644 index 0000000..56635cf --- /dev/null +++ b/src/sugoio/Sugoio.js @@ -0,0 +1,1133 @@ +/** + * @Author sugo.io + * @Date 17-11-18 + */ + +var Global = require('../global') +var Config = require('../config') +var _ = require('sugo-sdk-js-utils')['default'] +var CONSTANTS = require('../../constants') +var Track = require('./Track') + +var DEFAULT_CONFIG = require('./default-config') +var request = require('../request') +var API = require('../sugo-event-api') +var onDOMContentLoaded = require('./on-dom-content-loaded') + +/* ## Tracker */ +var LinkTracker = require('./LinkTracker') +var FormTracker = require('./FormTracker') +var People = require('./People') +var Persistence = require('./Persistence') +var Logger = require('../logger').get('Sugoio') + +var userAgent = navigator.userAgent +// http://hacks.mozilla.org/2009/07/cross-site-xmlhttprequest-with-cors/ +// https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#withCredentials +var USE_XHR = (window.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest()) + +// IE<10 does not support cross-origin XHR's but script tags +// with defer won't block window.onload; ENQUEUE_REQUESTS +// should only be true for Opera<12 +var ENQUEUE_REQUESTS = !USE_XHR && (userAgent.indexOf('MSIE') === -1) && (userAgent.indexOf('Mozilla') === -1) + +var DOM_LOADED = false +onDOMContentLoaded(function () { + DOM_LOADED = true +}) + +function Sugoio () { + this.name = null + this.people = null + this.proxy = null + this.config = null + this.persistence = null + this.cookie = null + + this.__autotrack_enabled = null + this.__loaded = null + this._jsc = null + this.__dom_loaded_queue = null + this.__request_queue = null + this.__disabled_events = null + this._flags = null +} + +/** + * create_sugoio(token:string, config:object, name:string) + * + * This function is used by the init method of Sugoio objects + * as well as the main initializer at the end of the JSLib (that + * initializes document.sugoio as well as any additional instances + * declared before this file has loaded). + * + * @param {string} token + * @param {object} config + * @param {string} name + * @return {Sugoio} + */ +Sugoio.create_sugoio = function (token, config, name) { + var master = Global.get() + var instance + var target = (name === CONSTANTS.PRIMARY_INSTANCE_NAME) ? master : master[name] + + // TODO 以是否为Array来判断太草率了些,改为属性值 + if (target && !_.isArray(target)) { + Logger.error('You have already initialized ' + name) + return target + } + + instance = new Sugoio() + instance._init(token, config, name) + instance.people = new People() + instance.people._init(instance) + instance.__autotrack_enabled = instance.get_config('autotrack') + + // if any instance on the page has debug = true, we set the + // global debug to be true + Config.debug = Config.debug || instance.get_config('debug') + + var dimensions = instance.get_config('dimensions') + if (dimensions) { + Config.dimensions = dimensions + } + + if (instance.get_config('autotrack')) { + var num_buckets = 100 + var num_enabled_buckets = 100 + if (!Track.track.enabledForProject(instance.get_config('token'), num_buckets, num_enabled_buckets)) { + instance.__autotrack_enabled = false + Logger.error('Not in active bucket: disabling Automatic Event Collection.') + } else if (!Track.track.isBrowserSupported()) { + instance.__autotrack_enabled = false + Logger.error('Disabling Automatic Event Collection because this browser is not supported') + } else { + Track.track.init(instance) + } + } + + // if target is not defined, we called init after the lib already + // loaded, so there won't be an array of things to execute + if (!_.isUndefined(target) && _.isArray(target)) { + // Crunch through the people queue first - we queue this data up & + // flush on identify, so it's better to do all these operations first + instance._execute_array.call(instance.people, target.people) + instance._execute_array(target) + } + + instance.proxy = target.proxy + + return instance +} + +Sugoio.prototype.version = process.env.SDK_VERSION + +/** + * 该方法在entry中会被覆写 + * @param {string} token + * @param {object} config + * @param {string} name + * @return {Sugoio} + */ +Sugoio.prototype.init = function (token, config, name) { + var master = Global.get() + if (_.isUndefined(name)) { + Logger.error('You must name your new library: init(token, config, name)') + return null + } + + if (name === CONSTANTS.PRIMARY_INSTANCE_NAME) { + Logger.error('You must initialize the main sugoio object right after you include the Sugoio js snippet') + return null + } + + var instance = Sugoio.create_sugoio(token, config, name) + master[name] = instance + instance._loaded() + + return instance +} + +/* ## Initialization methods */ + +/** + * This function initializes a new instance of the Sugoio tracking object. + * All new instances are added to the main sugoio object as sub properties (such as + * sugoio.library_name) and also returned by this function. To define a + * second instance on the page, you would call: + * + * sugoio.init('new token', { your: 'config' }, 'library_name'); + * + * and use it like so: + * + * sugoio.library_name.track(...); + * + * @param {String} token Your Sugoio API token + * @param {Object} [config] A dictionary of config options to override + * @param {String} [name] The name for the new sugoio instance that you want created + */ + +// sugoio._init(token:string, config:object, name:string) +// +// This function sets up the current instance of the sugoio +// library. The difference between this method and the init(...) +// method is this one initializes the actual instance, whereas the +// init(...) method sets up a new library and calls _init on it. +// +Sugoio.prototype._init = function (token, config, name) { + this.__loaded = true + this.name = name + this.config = {} + this.set_config(_.extend({}, DEFAULT_CONFIG, config, { + name: name, + token: token, + projectId: config.project_id, + cdn: config.app_host, + 'callback_fn': ((name === CONSTANTS.PRIMARY_INSTANCE_NAME) ? name : CONSTANTS.PRIMARY_INSTANCE_NAME + '.' + name) + '._jsc' + })) + + this._jsc = function () {} + + this.__dom_loaded_queue = [] + this.__request_queue = [] + this.__disabled_events = [] + this._flags = { + disable_all_events: false, + identify_called: false + } + + this.persistence = this.cookie = new Persistence(this.config) + this.register_once({ distinct_id: _.UUID() }, '') +} + +/* ## Private methods */ + +Sugoio.prototype._loaded = function () { + this.get_config('loaded')(this) + + // this happens after so a user can call identify/name_tag in + // the loaded callback + if (this.get_config('track_pageview')) { + this.track_pageview() + } +} + +Sugoio.prototype._dom_loaded = function () { + _.each(this.__dom_loaded_queue, function (item) { + this._track_dom.apply(this, item) + }, this) + _.each(this.__request_queue, function (item) { + this._send_request.apply(this, item) + }, this) + delete this.__dom_loaded_queue + delete this.__request_queue +} + +/** + * @param {DomTracker} DomClass + * @param args + * @return {*} + * @private + */ +Sugoio.prototype._track_dom = function (DomClass, args) { + if (this.get_config('img')) { + Logger.error('You can\'t use DOM tracking functions with img = true.') + return false + } + + if (!DOM_LOADED) { + this.__dom_loaded_queue.push([DomClass, args]) + return false + } + + var dt = new DomClass().init(this) + return dt.track.apply(dt, args) +} + +/** + * _prepare_callback() should be called by callers of _send_request for use + * as the callback argument. + * + * If there is no callback, this returns null. + * If we are going to make XHR/XDR requests, this returns a function. + * If we are going to use script tags, this returns a string to use as the + * callback GET param. + */ +Sugoio.prototype._prepare_callback = function (callback, data) { + if (_.isUndefined(callback)) { + return null + } + + if (USE_XHR) { + return function (response) { + callback(response, data) + } + } else { + // if the user gives us a callback, we store as a random + // property on this instances jsc function and update our + // callback string to reflect that. + var jsc = this._jsc + var randomized_cb = '' + Math.floor(Math.random() * 100000000) + var callback_string = this.get_config('callback_fn') + '[' + randomized_cb + ']' + jsc[randomized_cb] = function (response) { + delete jsc[randomized_cb] + callback(response, data) + } + return callback_string + } +} + +Sugoio.prototype._send_request = function (url, data, callback, encoded_data) { + if (ENQUEUE_REQUESTS) { + this.__request_queue.push(arguments) + return + } + + // needed to correctly format responses + var verbose_mode = this.get_config('verbose') + if (data.verbose) { verbose_mode = true } + if (this.get_config('test')) { data.test = 1 } + if (verbose_mode) { data.verbose = 1 } + if (this.get_config('img')) { data.img = 1 } + if (!USE_XHR) { + if (callback) { + data.callback = callback + } else if (verbose_mode || this.get_config('test')) { + // Verbose output (from verbose mode, or an error in test mode) is a json blob, + // which by itself is not valid javascript. Without a callback, this verbose output will + // cause an error when returned via jsonp, so we force a no-op callback param. + // See the ECMA script spec: http://www.ecma-international.org/ecma-262/5.1/#sec-12.4 + data.callback = '(function(){})' + } + } + + data.ip = this.get_config('ip') ? 1 : 0 + data._ = new Date().getTime().toString() + url += '?' + _.HTTPBuildQuery(data) + + if ('img' in data) { + var img = document.createElement('img') + img.src = url + document.body.appendChild(img) + } else if (USE_XHR && !data.data) { + try { + var req = new XMLHttpRequest() + req.open('GET', url, true) + // send the mp_optout cookie + // withCredentials cannot be modified until after calling .open on Android and Mobile Safari + req.withCredentials = false + req.onreadystatechange = function () { + if (req.readyState === 4) { // XMLHttpRequest.DONE == 4, except in safari 4 + if (url.indexOf('api.sugoio.com/track') !== -1) { + Track.track.checkForBackoff(req) + } + if (req.status === 200) { + if (callback) { + if (verbose_mode) { + callback(_.JSONDecode(req.responseText)) + } else { + callback(Number(req.responseText)) + } + } + } else { + var error = 'Bad HTTP status: ' + req.status + ' ' + req.statusText + Logger.error(error) + if (callback) { + if (verbose_mode) { + callback({ status: 0, error: error }) + } else { + callback(0) + } + } + } + } + } + req.send(null) + } catch (e) { + Logger.error(e) + } + } else if (USE_XHR && data.data) { // post上报信息到网关 + try { + + var reqPost = new XMLHttpRequest() + reqPost.open('POST', url, true) + // send the mp_optout cookie + // withCredentials cannot be modified until after calling .open on Android and Mobile Safari + reqPost.withCredentials = false + // reqPost.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + reqPost.onreadystatechange = function () { + if (reqPost.readyState === 4) { // XMLHttpRequest.DONE == 4, except in safari 4 + if (url.indexOf('api.sugoio.com/track') !== -1) { + Track.track.checkForBackoff(reqPost) + } + if (reqPost.status === 200) { + if (callback) { + if (verbose_mode) { + callback(_.JSONDecode(reqPost.responseText)) + } else { + callback(Number(reqPost.responseText)) + } + } + } else { + var error = 'Bad HTTP status: ' + reqPost.status + ' ' + reqPost.statusText + Logger.error(error) + if (callback) { + if (verbose_mode) { + callback({ status: 0, error: error }) + } else { + callback(0) + } + } + } + } + } + reqPost.send(encoded_data) + } catch (e) { + Logger.error(e) + } + } else { + var script = document.createElement('script') + script.type = 'text/javascript' + script.async = true + script.defer = true + script.src = url + var s = document.getElementsByTagName('script')[0] + s.parentNode.insertBefore(script, s) + } +} + +/** + * _execute_array() deals with processing any sugoio function + * calls that were called before the Sugoio library were loaded + * (and are thus stored in an array so they can be called later) + * + * Note: we fire off all the sugoio function calls && user defined + * functions BEFORE we fire off sugoio tracking calls. This is so + * identify/register/set_config calls can properly modify early + * tracking calls. + * + * @param {Array} array + */ +Sugoio.prototype._execute_array = function (array) { + var fn_name, alias_calls = [], other_calls = [], tracking_calls = [] + _.each(array, function (item) { + if (item) { + fn_name = item[0] + if (typeof(item) === 'function') { + item.call(this) + } else if (_.isArray(item) && fn_name === 'alias') { + alias_calls.push(item) + } else if (_.isArray(item) && fn_name.indexOf('track') !== -1 && typeof(this[fn_name]) === 'function') { + tracking_calls.push(item) + } else { + other_calls.push(item) + } + } + }, this) + + var execute = function (calls, context) { + _.each(calls, function (item) { + this[item[0]].apply(this, item.slice(1)) + }, context) + } + + execute(alias_calls, this) + execute(other_calls, this) + execute(tracking_calls, this) +} + +/** + * push() keeps the standard async-array-push + * behavior around after the lib is loaded. + * This is only useful for external integrations that + * do not wish to rely on our convenience methods + * (created in the snippet). + * + * ### Usage: + * sugoio.push(['register', { a: 'b' }]); + * + * @param {Array} item A [function_name, args...] array to be executed + */ +Sugoio.prototype.push = function (item) { + this._execute_array([item]) +} + +/** + * Disable events on the Sugoio object. If passed no arguments, + * this function disables tracking of any event. If passed an + * array of event names, those events will be disabled, but other + * events will continue to be tracked. + * + * Note: this function does not stop other sugoio functions from + * firing, such as register() or people.set(). + * + * @param {Array} [events] An array of event names to disable + */ +Sugoio.prototype.disable = function (events) { + if (typeof(events) === 'undefined') { + this._flags.disable_all_events = true + } else { + this.__disabled_events = this.__disabled_events.concat(events) + } +} + +/** + * Track an event. This is the most important and + * frequently used Sugoio function. + * + * ### Usage: + * + * // track an event named 'Registered' + * sugoio.track('Registered', {'Gender': 'Male', 'Age': 21}); + * + * To track link clicks or form submissions, see track_links() or track_forms(). + * + * @param {String} event_name The name of the event. This can be anything the user does - 'Button Click', 'Sign Up', 'Item Purchased', etc. + * @param {Object} [properties] A set of properties to include with the event you're sending. These describe the user who did the event or details about the event itself. + * @param {Function} [callback] If provided, the callback function will be called after tracking the event. + */ +Sugoio.prototype.track = function (event_name, properties, callback) { + var master = Global.get() + if (!_.isFunction(callback)) { + callback = _.noop + } + + if (_.isUndefined(event_name)) { + Logger.error('No event name provided to sugoio.track') + return + } + + if (this._event_is_disabled(event_name)) { + callback(0) + return + } + + // set defaults + properties = properties || {} + properties.token = this.get_config('token') + + // set duration if time_event was previously called for this event + var start_timestamp = this.persistence.remove_event_timer(event_name) + if (!_.isUndefined(start_timestamp)) { + var duration_in_ms = new Date().getTime() - start_timestamp + properties.duration = parseFloat((duration_in_ms / 1000).toFixed(2)) + properties.event_type = 'duration' //增加停留事件类型 + properties.host = location.host + } + + // update persistence + this.persistence.update_search_keyword(document.referrer) + + if (this.get_config('store_google')) { + this.persistence.update_campaign_params() + } + + if (this.get_config('save_referrer')) { + this.persistence.update_referrer_info(document.referrer) + } + + // note: extend writes to the first object, so lets make sure we + // don't write to the persistence properties object and info + // properties object by passing in a new object + // update properties with pageview info and super-properties + const enable_hash = this.get_config('enable_hash') || false + const hash = enable_hash ? location.hash : '' + properties = _.extend( + { + event_type: 'click', // default event_type + path_name: location.pathname + hash, + page_category: master.page_category + }, + _.info.properties(), + // referrer may be in cookie + _.omit(this.persistence.properties(), ['referrer']), + { sdk_version: this.version || Global.Global.version }, + properties + ) + + var realPeople = this._getRealPeopleJson() + properties = _.extend({}, properties, realPeople) + + var property_blacklist = this.get_config('property_blacklist') + if (_.isArray(property_blacklist)) { + _.each(property_blacklist, function (blacklisted_prop) { + delete properties[blacklisted_prop] + }) + } else { + Logger.error('Invalid value for property_blacklist config: ' + property_blacklist) + } + + var encode_type = this.get_config('encode_type') + var isJSON = encode_type && encode_type === 'json' + var data, + serverDimensions = Global.take('serverDimensions'), + dimensions = this.get_config('dimensions') + + var defaultProps = { 'event_name': event_name } + if (properties.event_id) { + defaultProps.event_id = properties.event_id + } + + // 如果代码埋点设置了page_name则不覆盖,以代码埋点优先 + if (!properties.page_name) { + // 设置自定义页面名称 + // 默认当前页面title + var pageInfo = Global.take('pageInfo') + if (pageInfo && pageInfo.page_name) { + properties.page_name = pageInfo.page_name + } else { + properties.page_name = document.title + } + } + + var session_id = this.get_property('session_id') + if (!session_id) { + session_id = _.shortUUID() + var expire_days = 1 //session_id expire_days + this.register_once({ 'session_id': session_id }, '', expire_days) + } + properties.session_id = session_id + + if (isJSON) { //json数据包需要数值包裹 + data = [_.extend(defaultProps, properties)] + } else { + data = _.extend(defaultProps, properties) + } + var truncated_data = _.truncate(data, 255) + var json_data = isJSON + ? _.JSONEncode(truncated_data) + : _.PlainEncode(truncated_data, dimensions, serverDimensions) + + if (!json_data) { + Logger.error('Empty content. event name is => '.concat(event_name)) + Logger.log('Please check the length of dimensions that from server.') + } + + var encoded_data = _.base64Encode([json_data]) + Logger.log('json_data:', json_data) + Logger.log('SUGOIO REQUEST: %o', truncated_data) + + var uri = this.get_config('api_host') + + '/post?' + + 'locate=' + this.get_config('project_id') + + '&token=' + this.get_config('token') + + request().send('POST', uri, encoded_data).success(function () { + callback(truncated_data) + }) + + return truncated_data +} + +/** + * Track a page view event, which is currently ignored by the server. + * This function is called by default on page load unless the + * track_pageview configuration variable is false. + * + * @param {String} [page] The url of the page to record. If you don't include this, it defaults to the current url. + * @api private + */ +Sugoio.prototype.track_pageview = function (page) { + const enable_hash = this.get_config('enable_hash') || false + const hash = enable_hash ? location.hash : '' + if (_.isUndefined(page)) { + page = location.pathname + hash + } + this.track('页面加载', _.info.pageviewInfo(page)) +} + +/** + * Track clicks on a set of document elements. Selector must be a + * valid query. Elements must exist on the page at the time track_links is called. + * + * ### Usage: + * + * // track click for link id #nav + * sugoio.track_links('#nav', 'Clicked Nav Link'); + * + * ### Notes: + * + * This function will wait up to 300 ms for the Sugoio + * servers to respond. If they have not responded by that time + * it will head to the link without ensuring that your event + * has been tracked. To configure this timeout please see the + * set_config() documentation below. + * + * If you pass a function in as the properties argument, the + * function will receive the DOMElement that triggered the + * event as an argument. You are expected to return an object + * from the function; any properties defined on this object + * will be sent to sugoio as event properties. + * + * @type {Function} + * @param {Object|String} query A valid DOM query, element or jQuery-esque list + * @param {String} event_name The name of the event to track + * @param {Object|Function} [properties] A properties object or function that returns a dictionary of properties when passed a DOMElement + */ +Sugoio.prototype.track_links = function (query, event_name, properties) { + return this._track_dom.call(this, LinkTracker, arguments) +} + +/** + * Track form submissions. Selector must be a valid query. + * + * ### Usage: + * + * // track submission for form id 'register' + * sugoio.track_forms('#register', 'Created Account'); + * + * ### Notes: + * + * This function will wait up to 300 ms for the sugoio + * servers to respond, if they have not responded by that time + * it will head to the link without ensuring that your event + * has been tracked. To configure this timeout please see the + * set_config() documentation below. + * + * If you pass a function in as the properties argument, the + * function will receive the DOMElement that triggered the + * event as an argument. You are expected to return an object + * from the function; any properties defined on this object + * will be sent to sugoio as event properties. + * + * @type {Function} + * @param {Object|String} query A valid DOM query, element or jQuery-esque list + * @param {String} event_name The name of the event to track + * @param {Object|Function} [properties] This can be a set of properties, or a function that returns a set of properties after being passed a DOMElement + */ +Sugoio.prototype.track_forms = function (query, event_name, properties) { + return this._track_dom.call(this, FormTracker, arguments) +} + +/** + * Time an event by including the time between this call and a + * later 'track' call for the same event in the properties sent + * with the event. + * + * ### Usage: + * + * // time an event named 'Registered' + * sugoio.time_event('Registered'); + * sugoio.track('Registered', {'Gender': 'Male', 'Age': 21}); + * + * When called for a particular event name, the next track call for that event + * name will include the elapsed time between the 'time_event' and 'track' + * calls. This value is stored as seconds in the 'duration' property. + * + * @param {String} event_name The name of the event. + * @return {Sugoio} + */ +Sugoio.prototype.time_event = function (event_name) { + if (_.isUndefined(event_name)) { + Logger.error('No event name provided to sugoio.time_event') + return this + } + + if (this._event_is_disabled(event_name)) { + return this + } + + this.persistence.set_event_timer(event_name, new Date().getTime()) + return this +} + +/** + * Register a set of super properties, which are included with all + * events. This will overwrite previous super property values. + * + * ### Usage: + * + * // register 'Gender' as a super property + * sugoio.register({'Gender': 'Female'}); + * + * // register several super properties when a user signs up + * sugoio.register({ + * 'Email': 'jdoe@example.com', + * 'Account Type': 'Free' + * }); + * + * @param {Object} props An associative array of properties to store about the user + * @param {Number} [days] How many days since the user's last visit to store the super properties + */ +Sugoio.prototype.register = function (props, days) { + this.persistence.register(props, days) +} + +/** + * Register a set of super properties only once. This will not + * overwrite previous super property values, unlike register(). + * + * ### Usage: + * + * // register a super property for the first time only + * sugoio.register_once({ + * 'First Login Date': new Date().toISOString() + * }); + * + * ### Notes: + * + * If default_value is specified, current super properties + * with that value will be overwritten. + * + * @param {Object} props An associative array of properties to store about the user + * @param {*} [default_value] Value to override if already set in super properties (ex: 'False') Default: 'None' + * @param {Number} [days] How many days since the users last visit to store the super properties + */ +Sugoio.prototype.register_once = function (props, default_value, days) { + this.persistence.register_once(props, default_value, days) +} + +/** + * Delete a super property stored with the current user. + * + * @param {String} property The name of the super property to remove + */ +Sugoio.prototype.unregister = function (property) { + this.persistence.unregister(property) +} + +Sugoio.prototype._register_single = function (prop, value) { + var props = {} + props[prop] = value + this.register(props) +} + +/** + * Clears super properties and generates a new random client_distinct_id for this instance. + * Useful for clearing data when a user logs out. + */ +Sugoio.prototype.reset = function () { + this.persistence.clear() + this._flags.identify_called = false + this.register_once({ 'distinct_id': _.UUID() }, '') +} + +/** + * Returns the current distinct id of the user. This is either the id automatically + * generated by the library or the id that has been passed by a call to identify(). + * + * ### Notes: + * + * get_distinct_id() can only be called after the Sugoio library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // set client_distinct_id after the sugoio library has loaded + * sugoio.init('YOUR PROJECT TOKEN', { + * loaded: function(sugoio) { + * client_distinct_id = sugoio.get_distinct_id(); + * } + * }); + */ +Sugoio.prototype.get_distinct_id = function () { + return this.get_property('distinct_id') +} + +/** + * Provide a string to recognize the user by. The string passed to + * this method will appear in the Sugoio Streams product rather + * than an automatically generated name. Name tags do not have to + * be unique. + * + * This value will only be included in Streams data. + * + * @param {String} name_tag A human readable name for the user + * @api private + */ +Sugoio.prototype.name_tag = function (name_tag) { + this._register_single('sg_name_tag', name_tag) +} + +/** + * Update the configuration of a sugoio library instance. + * + * The default config is: + * + * { + * // super properties cookie expiration (in days) + * cookie_expiration: 365 + * + * // super properties span subdomains + * cross_subdomain_cookie: true + * + * // if this is true, the sugoio cookie or localStorage entry + * // will be deleted, and no user persistence will take place + * disable_persistence: false + * + * // type of persistent store for super properties (cookie/ + * // localStorage) if set to 'localStorage', any existing + * // sugoio cookie value with the same persistence_name + * // will be transferred to localStorage and deleted + * persistence: 'cookie' + * + * // name for super properties persistent store + * persistence_name: '' + * + * // names of properties/superproperties which should never + * // be sent with track() calls + * property_blacklist: [] + * + * // if this is true, sugoio cookies will be marked as + * // secure, meaning they will only be transmitted over https + * secure_cookie: false + * + * // the amount of time track_links will + * // wait for Sugoio's servers to respond + * track_links_timeout: 300 + * + * // should we track a page view on page load + * track_pageview: true + * + * // if you set upgrade to be true, the library will check for + * // a cookie from our old js library and import super + * // properties from it, then the old cookie is deleted + * // The upgrade config option only works in the initialization, + * // so make sure you set it when you create the library. + * upgrade: false + * } + * + * + * @param {Object} config A dictionary of new configuration values to update + */ +Sugoio.prototype.set_config = function (config) { + if (_.isObject(config)) { + _.extend(this.config, config) + + if (!this.get_config('persistence_name')) { + this.config.persistence_name = this.config.cookie_name + } + if (!this.get_config('disable_persistence')) { + this.config.disable_persistence = this.config.disable_cookie + } + + if (this.persistence) { + this.persistence.update_config(this.config) + } + Config.debug = Config.debug || this.get_config('debug') + } +} + +/** + * returns the current config object for the library. + */ +Sugoio.prototype.get_config = function (prop_name) { + return this.config[prop_name] +} + +/** + * Returns the value of the super property named property_name. If no such + * property is set, get_property() will return the undefined value. + * + * ### Notes: + * + * get_property() can only be called after the Sugoio library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // grab value for 'user_id' after the sugoio library has loaded + * sugoio.init('YOUR PROJECT TOKEN', { + * loaded: function(sugoio) { + * user_id = sugoio.get_property('user_id'); + * } + * }); + * + * @param {String} property_name The name of the super property you want to retrieve + */ +Sugoio.prototype.get_property = function (property_name) { + return this.persistence.props[property_name] +} + +/** + * 生成用户真实id维度名 + * @param {string} dimension + * @return {?string} + */ +Sugoio.createPeopleDimension = function (dimension) { + if (!_.isString(dimension) || dimension === '') { + return null + } + + return CONSTANTS.PEOPLE_REAL_USER_ID_DIMENSION_PREFIX + dimension +} + +/** + * 提取用户真实id维度 + * @param {string} dimension + * @return {?string} + */ +Sugoio.extractPeopleDimension = function (dimension) { + if (!_.isString(dimension) || dimension === '') { + return null + } + + var i = dimension.indexOf(CONSTANTS.PEOPLE_REAL_USER_ID_DIMENSION_PREFIX) + if (i === 0) { + return dimension.replace(CONSTANTS.PEOPLE_REAL_USER_ID_DIMENSION_PREFIX, '') + } + return dimension +} + +/** + * @param {string} dimension + * @return {?string} + * @private + */ +Sugoio.prototype._getRealPeople = function (dimension) { + dimension = Sugoio.createPeopleDimension(dimension) + return dimension ? this.persistence.props[dimension] : null +} + +/** + * 生成用户真实ID维度json + * @return {{}} + * @private + */ +Sugoio.prototype._getRealPeopleJson = function () { + var props = this.persistence.props + var key = null + + for (var prop in props) { + if (!props.hasOwnProperty(prop)) continue + if (prop.indexOf(CONSTANTS.PEOPLE_REAL_USER_ID_DIMENSION_PREFIX) === 0) { + key = prop + break + } + } + + var result = {} + if (!key) return result + + var dimension = Sugoio.extractPeopleDimension(key) + if (!dimension) return result + + result[dimension] = props[key] + + return result +} + +/** + * 录用户Id + * 函数功能实现如下: + * 先判断cookie(pc, 移动端存储本地)里是否已存在此登录id的首次登陆时间, + * 如果不存在则发起请求向服务端获取首次登陆时间,然后上报一条首次访问的时间, + * 并存储到cookie(pc, 移动端存储本地) + * 后台接口采用redis存储用户登录ID映射表 + * @param {string} user_id + * @param {string|function} [user_real_dimension] + * @param {function(err: Error|null)} [callback] + * @return {Sugoio} + */ +Sugoio.prototype.track_first_time = function (user_id, user_real_dimension, callback) { + if (_.isUndefined(user_id) || user_id === '') { + Logger.error('Track first time param error: user_id required') + return this + } + + // track_first_time(user_id, callback) + if (_.isFunction(user_real_dimension)) { + callback = user_real_dimension + user_real_dimension = void 0 + } + + // track_first_time(user_id, user_real_dimension) + if (_.isString(user_real_dimension) && _.isUndefined(callback)) { + callback = _.noop + } + + var user = this._getRealPeople(user_real_dimension) + var self = this + + if (user === user_id) { + callback(null) + return this + } + + // 向服务端注册,更新cookie存储 + API.getFirstLoginTime(user_id, this.get_config('token')) + .success(function (res) { + /** @type {GetFirstLoginTimeResponseStruct} */ + var proxy = res + if (!proxy.success) { + return Logger.error('Fetch first login error: %s', proxy.message) + } + // 首次登录 + // 将 user_id, first_login_time 写入cookie + // 并发送首次登录事件 + + // 由于用户可能手动清理了cookie,所以每次验证的时候都写一次 + var obj = {} + obj[CONSTANTS.FIRST_LOGIN_TIME] = proxy.result.firstLoginTime + + var dimension = Sugoio.createPeopleDimension(user_real_dimension) + if (dimension) { + obj[dimension] = user_id + } + + self.register(obj) + + if (proxy.result.isFirstLogin) { + // 发送首次登录事件 + Logger.info('Store user first login information to cookie: %o', obj) + self.track(CONSTANTS.FIRST_LOGIN_EVENT_NAME, Track.track._getDefaultProperties('first_login')) + Logger.info('Track first login event: %s', CONSTANTS.FIRST_LOGIN_EVENT_NAME) + } + + callback(null) + }) + .error(function (err) { + Logger.error('Fetch first login error: %s', err.message) + callback(err || new Error('查询首次登录时间出错')) + }) + + return this +} + +/** + * 清除真实用户id + * @param {string} user_id + * @param {string} user_real_dimension + * @return {Sugoio} + */ +Sugoio.prototype.clear_first_login = function (user_id, user_real_dimension) { + + if (!_.isString(user_id) || user_id === '') { + Logger.error('Track clear first login param error: user_id required and must be string') + return this + } + + if (!_.isString(user_real_dimension) || user_real_dimension === '') { + Logger.error('Track clear first login param error: user_real_dimension required and must be string') + return this + } + + var dimension = Sugoio.createPeopleDimension(user_real_dimension) + var user = this._getRealPeople(user_real_dimension) + + if (user !== user_id) return this + + // 清除真实user_id,首次登录时间,维度名 + if (dimension) { + this.unregister(dimension) + } + + this.unregister(CONSTANTS.FIRST_LOGIN_TIME) + return this +} + +Sugoio.prototype.toString = function () { + var name = this.get_config('name') + if (name !== CONSTANTS.PRIMARY_INSTANCE_NAME) { + name = CONSTANTS.PRIMARY_INSTANCE_NAME + '.' + name + } + return name +} + +Sugoio.prototype._event_is_disabled = function (event_name) { + return _.isBlockedUA(userAgent) || this._flags.disable_all_events || _.include(this.__disabled_events, event_name) +} + +Sugoio.prototype.updateSessionId = function(){ + var session_id = _.shortUUID() + var expire_days = 1 //session_id expire_days + this.register({ 'session_id': session_id }, expire_days) +} + +module.exports = Sugoio diff --git a/src/sugoio/Track.js b/src/sugoio/Track.js new file mode 100644 index 0000000..9c356b3 --- /dev/null +++ b/src/sugoio/Track.js @@ -0,0 +1,815 @@ +/** + * @Author sugo.io + * @Date 17-11-18 + */ + +var Config = require('../config') +var _ = require('sugo-sdk-js-utils')['default'] +var Events = require('./events') +var match_pathname = require('../match-pathname') +var match_page = require('../match-page') +var Regulation = require('../regulation-parser') +var Selector = require('../css-path') +var API = require('../sugo-event-api') +var SugoIO = require('../global') +var Debugger = require('../debugger') +var request = require('../request') +var CONSTANTS = require('../../constants') +var Logger = require('../logger').get('Track') + +var DISABLE_COOKIE = '__sgced' + +// specifying these locally here since some websites override the global Node var +// ex: https://www.codingame.com/ +var ELEMENT_NODE = 1 +var TEXT_NODE = 3 + +var track = { + _initializedTokens: [], + + _previousElementSibling: function (el) { + if (el.previousElementSibling) { + return el.previousElementSibling + } else { + do { + el = el.previousSibling + } while (el && el.nodeType !== ELEMENT_NODE) + return el + } + }, + + _loadScript: function (scriptUrlToLoad, callback) { + var scriptTag = document.createElement('script') + scriptTag.type = 'text/javascript' + scriptTag.src = scriptUrlToLoad + scriptTag.onload = callback + + var scripts = document.getElementsByTagName('script') + if (scripts.length > 0) { + scripts[0].parentNode.insertBefore(scriptTag, scripts[0]) + } else { + document.body.appendChild(scriptTag) + } + }, + + _loadCss: function (url, callback) { + var stylesheet = document.createElement('link') + stylesheet.href = url + stylesheet.rel = 'stylesheet' + stylesheet.type = 'text/css' + // temporarily set media to something inapplicable to ensure it'll fetch without blocking render + stylesheet.media = 'only x' + // set the media back when the stylesheet loads + stylesheet.onload = function () { + stylesheet.media = 'all' + callback() + } + document.getElementsByTagName('head')[0].appendChild(stylesheet) + }, + + _getClassName: function (elem) { + switch (typeof elem.className) { + case 'string': + return elem.className + case 'object': // handle cases where className might be SVGAnimatedString or some other type + return elem.className.baseVal || elem.getAttribute('class') || '' + default: // future proof + return '' + } + }, + + _getPropertiesFromElement: function (elem) { + var props = { + 'classes': this._getClassName(elem).split(' '), + 'tag_name': elem.tagName.toLowerCase() + } + + if (_.include(['input', 'select', 'textarea'], elem.tagName.toLowerCase())) { + var formFieldValue = this._getFormFieldValue(elem) + if (this._includeProperty(elem, formFieldValue)) { + props.value = formFieldValue + } + } + + _.each(elem.attributes, function (attr) { + props['attr__' + attr.name] = attr.value + }) + + var nthChild = 1 + var nthOfType = 1 + var currentElem = elem + while (currentElem = this._previousElementSibling(currentElem)) { // eslint-disable-line no-cond-assign + nthChild++ + if (currentElem.tagName === elem.tagName) { + nthOfType++ + } + } + props.nth_child = nthChild + props.nth_of_type = nthOfType + + return props + }, + + /* + * Due to potential reference discrepancies (such as the webcomponents.js polyfill) + * We want to match tagNames instead of specific reference because something like element === document.body + * won't always work because element might not be a native element. + */ + _isTag: function (el, tag) { + return el && el.tagName && el.tagName.toLowerCase() === tag.toLowerCase() + }, + + _shouldTrackDomEvent: function (element, event) { + if (!element || this._isTag(element, 'html') || element.nodeType !== ELEMENT_NODE) { + return false + } + var tag = element.tagName.toLowerCase() + switch (tag) { + case 'html': + return false + case 'form': + return event.type === 'submit' + case 'input': + if (_.indexOf(['button', 'submit'], element.getAttribute('type')) === -1) { + return event.type === 'change' || event.type === 'focus' + } else { + return event.type === 'click' + } + case 'select': + case 'textarea': + return event.type === 'change' + default: + return event.type === 'click' + } + }, + + _getDefaultProperties: function (eventType) { + const ins = SugoIO.get() + const enable_hash = ins.get_config('enable_hash') || false + const hash = enable_hash ? location.hash : '' + return _.extend(_.info.properties(), { + 'event_type': eventType, + 'host': window.location.host, + 'path_name': window.location.pathname + hash, + 'sdk_version': SugoIO.Global.version + }) + }, + + _getInputValue: function (input) { + var value = null + var type = input.type.toLowerCase() + switch (type) { + case 'checkbox': + if (input.checked) { + value = [input.value] + } + break + case 'radio': + if (input.checked) { + value = input.value + } + break + default: + value = input.value + break + } + return value + }, + + _getSelectValue: function (select) { + var value + if (select.multiple) { + var values = [] + _.each(_.querySelectorAll('[selected]', select), function (option) { + values.push(option.value) + }) + value = values + } else { + value = select.value + } + return value + }, + + _includeProperty: function (input, value) { + for (var curEl = input; curEl.parentNode && !this._isTag(curEl, 'body'); curEl = curEl.parentNode) { + var classes = this._getClassName(curEl).split(' ') + if (_.include(classes, 'sugoio-sensitive') || _.include(classes, 'sugoio-no-track')) { + return false + } + } + + if (_.include(this._getClassName(input).split(' '), 'sugoio-include')) { + return true + } + + if (value === null) { + return false + } + + // don't include hidden or password fields + var type = input.type || '' + switch (type.toLowerCase()) { + case 'hidden': + return false + case 'password': + return false + } + + // filter out data from fields that look like sensitive fields + var name = input.name || input.id || '' + var sensitiveNameRegex = /^cc|cardnum|ccnum|creditcard|csc|cvc|cvv|exp|pass|seccode|securitycode|securitynum|socialsec|socsec|ssn/i + if (sensitiveNameRegex.test(name.replace(/[^a-zA-Z0-9]/g, ''))) { + return false + } + + if (typeof value === 'string') { + // check to see if input value looks like a credit card number + // see: https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9781449327453/ch04s20.html + var ccRegex = /^(?:(4[0-9]{12}(?:[0-9]{3})?)|(5[1-5][0-9]{14})|(6(?:011|5[0-9]{2})[0-9]{12})|(3[47][0-9]{13})|(3(?:0[0-5]|[68][0-9])[0-9]{11})|((?:2131|1800|35[0-9]{3})[0-9]{11}))$/ + if (ccRegex.test((value || '').replace(/[\- ]/g, ''))) { + return false + } + + // check to see if input value looks like a social security number + var ssnRegex = /(^\d{3}-?\d{2}-?\d{4}$)/ + if (ssnRegex.test(value)) { + return false + } + } + + return true + }, + + _getFormFieldValue: function (field) { + var val + switch (field.tagName.toLowerCase()) { + case 'input': + val = this._getInputValue(field) + break + case 'select': + val = this._getSelectValue(field) + break + default: + val = field.value || field.textContent + break + } + return this._includeProperty(field, val) ? val : null + }, + + _getFormFieldProperties: function (form) { + var formFieldProps = {} + _.each(form.elements, function (field) { + var name = field.getAttribute('name') || field.getAttribute('id') + if (name !== null) { + name = 'form_field__' + name + var val = this._getFormFieldValue(field) + if (this._includeProperty(field, val)) { + var prevFieldVal = formFieldProps[name] + if (prevFieldVal !== undefined) { // combine values for inputs of same name + formFieldProps[name] = [].concat(prevFieldVal, val) + } else { + formFieldProps[name] = val + } + } + } + }, this) + return formFieldProps + }, + + _extractCustomPropertyValue: function (customProperty) { + return this._extractElementsValue(customProperty.css_selector).join(',') + }, + + /** + * @param {String} selector + * @return {Array} + * @private + */ + _extractElementsValue: function (selector) { + var values = [] + + if (!_.isString(selector)) { + return values + } + + _.each(_.querySelectorAll(selector), function (elem) { + if (_.indexOf(['input', 'select'], elem.tagName.toLowerCase()) > -1) { + values.push(elem.value) + } else if (elem.textContent) { + values.push(elem.textContent) + } + }) + + return values + }, + + /** + * 获取关联元素内容 + * @param {Array} binds + * @param {string} parentSimilarPath + * @return {Object} + * @private + */ + _getAssociatesElementsProps: function (binds, parentSimilarPath) { + + // 上报相关的维度开启同类 + const getChildSimilarPath = (p_path, c_path) => { + // 获取完整层级path进行比较 + c_path = Selector.entire(document.querySelector(c_path)) + let sameIdx = 0 + // 对比找出控件 和上报维度相同内容 + let pathLength = p_path.length + for (let i = 0; i < pathLength; i++) { + if (p_path.charAt(i) !== c_path.charAt(i)) { + sameIdx = i + break + } + } + // 生成新的path 获上报控件 + var similarIdex = p_path.substr(sameIdx).substring(0, p_path.substr(sameIdx).indexOf(')')) + var similarEndPath = c_path.substr(sameIdx).substring(c_path.substr(sameIdx).indexOf(')')) + return c_path.substring(0, sameIdx) + similarIdex + similarEndPath + } + + return (_.isArray(binds) ? binds : []).reduce(function (p, c) { + let path = c.path + // 上报相关的维度开启同类 + if (c.similar) { // 根据父同类元素path获取关联元素对应同类path + path = getChildSimilarPath(parentSimilarPath, path) + } + p[c.dimension] = this._extractElementsValue(path).join(',') + return p + }.bind(this), {}) + }, + + _getCustomProperties: function (targetElementList) { + var props = {} + _.each(this._customProperties.events, function (customProperty) { + // _.each(customProperty.event_selectors, function(eventSelector) { + var selectors = customProperty.event_path + if (!_.isArray(selectors)) { + selectors = [selectors] + } + _.each(selectors, function (eventSelector) { + var eventElements = _.querySelectorAll(eventSelector) + _.each(eventElements, function (eventElement) { + if (_.include(targetElementList, eventElement)) { + props[customProperty.event_name] = this._extractCustomPropertyValue(customProperty) + } + }, this) + }, this) + }, this) + return props + }, + + checkForBackoff: function (resp) { + // temporarily stop CE for X seconds if the 'X-MP-CE-Backoff' header says to + var secondsToDisable = parseInt(resp.getResponseHeader('X-MP-CE-Backoff')) + if (!isNaN(secondsToDisable) && secondsToDisable > 0) { + var disableUntil = _.timestamp() + (secondsToDisable * 1000) + Logger.log('disabling CE for ' + secondsToDisable + ' seconds (from ' + _.timestamp() + ' until ' + disableUntil + ')') + _.cookie.set_seconds(DISABLE_COOKIE, true, secondsToDisable, true) + } + }, + + _getEventTarget: function (e) { + // https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes + if (typeof e.target === 'undefined') { + return e.srcElement + } else { + return e.target + } + }, + + _trackEvent: function (e, instance, events, pageInfo) { + var eventType = e.type + /*** Don't mess with this code without running IE8 tests on it ***/ + var target = this._getEventTarget(e) + if (target.nodeType === TEXT_NODE) { // defeat Safari bug (see: http://www.quirksmode.org/js/events_properties.html) + target = target.parentNode + } + + if (this._shouldTrackDomEvent(target, e)) { + var targetElementList = [target] + var curEl = target + while (curEl.parentNode && !this._isTag(curEl, 'body')) { + targetElementList.push(curEl.parentNode) + curEl = curEl.parentNode + } + + // var elementsJson = [] + var href, elementText = '', form, explicitNoTrack = false + _.each(targetElementList, function (el, idx) { + // if the element or a parent element is an anchor tag + // include the href as a property + if (el.tagName.toLowerCase() === 'a') { + href = el.getAttribute('href') + } else if (el.tagName.toLowerCase() === 'form') { + form = el + } + // crawl up to max of 5 nodes to populate text content + if (!elementText && idx < 5 && el.textContent) { + var textContent = _.trim(el.textContent) + if (textContent) { + elementText = textContent.replace(/[\r\n]/g, ' ').replace(/[ ]+/g, ' ').substring(0, 255) + } + } + + // allow users to programatically prevent tracking of elements by adding class 'sugoio-no-track' + var classes = this._getClassName(el).split(' ') + if (_.include(classes, 'sugoio-no-track')) { + explicitNoTrack = true + } + + // elementsJson.push(this._getPropertiesFromElement(el)) + }, this) + + if (explicitNoTrack) { + return false + } + var props = _.extend( + this._getDefaultProperties(eventType), { + 'event_label': elementText + }) + if (form && (eventType === 'submit' || eventType === 'click')) { + _.extend(props, this._getFormFieldProperties(form)) + } + + _.each(events, _.bind(function (obj) { + // 过滤事件类型 + if (obj.event_type !== eventType) return + + // 关联维度 + var binds = obj.binds || {} + var bindsArray = _.map(_.keys(binds), function (dimension) { + var record = binds[dimension] + return { + dimension: dimension, + path: record.path, + similar: record.similar + } + }) + + // 如果是同类元素,也上报数据 + // TODO 性能优化 使用similar_path匹配同类元素 + + var path = obj.similar === true ? (obj.similar_path || _.similar(obj.event_path)) : obj.event_path + var elems = _.querySelectorAll(path) + + _.each(elems, function (elem) { + + if (Events.contains(elem, target)) { + var event_name = obj.event_name || 'web_event' + var code = _.trim(obj.code || '') + var parentSimilarPath = Selector.entire(elem) + + // 确认有效关联元素 + _.extend(props, this._getAssociatesElementsProps(bindsArray, parentSimilarPath)) + + if (code) { + try { + // web注入代码埋点 + // 代码注入失败不影响整个上报记录 + var sugo_props = new Function('e', 'element', 'conf', 'instance', code) + var custom_props = sugo_props(e, elem, obj, instance) || {} + custom_props.from_binding = true + + if (_.isObject(custom_props)) { + _.extend(props, custom_props) + } + } catch (e) { + Logger.log('sugoio track code err => ' + e.message) + } + } + + props.event_id = obj.event_id + instance.track(event_name, props) + } + }, this) + + }, this)) + + return true + } + }, + + // only reason is to stub for unit tests + // since you can't override window.location props + _navigate: function (href) { + window.location.href = href + }, + + _addDomEventHandlers: function (instance) { + Logger.log('_addDomEventHandlers', instance) + var that = this + //根据可视化配置绑定上报事件 + if (this._customProperties && this._customProperties.events.length > 0) { + // 改为代理事件以保证动态渲染的dom事件绑定有效 + var handler = _.bind(function (e) { + if (_.cookie.parse(DISABLE_COOKIE) !== true) { + e = e || window.event + this._trackEvent(e, instance, this._customProperties.events, this._customProperties.pageInfo) + } + }, this) + _.register_event(document, 'focus', handler, false, true) + // _.register_event(document, 'submit', handler, false, true) + _.register_event(document, 'change', handler, false, true) + _.register_event(document, 'click', handler, false, true) + } + }, + _customProperties: {}, + init: function (instance) { + if (!(document && document.body)) { + Logger.log('document not ready yet, trying again in 500 milliseconds...') + setTimeout(() => { + this.init(instance) + }, 500) + return + } + + var token = instance.get_config('token') + if (_.indexOf(this._initializedTokens, token) > -1) { + return Logger.log('autotrack already initialized for token "' + token + '"') + } + this._initializedTokens.push(token) + if (!this._maybeLoadEditor(instance)) { + const enable_hash = instance.get_config('enable_hash') || false + // decide callbak + var parseDecideResponse = _.bind(function (response) { + if (!response.success) { + return Logger.error(response.message) + } + + var res = null + + // 捕获服务端返回异常空数据引发的bug + try { + res = _.JSONDecode(_.decompressUrlQuery(response.result)) + } catch (e) { + Logger.error(e.message) + Logger.error(e.stack) + } + + if (!res) return + + Logger.debug('Page decide: %o', res) + + if (res.config.enable_collect_everything === true) { + let url = this._getCurrentUrl(enable_hash) + var pageInfo = match_page(url, res.page_info) || {} + var serverDimensions = res.dimensions + if (!serverDimensions || serverDimensions.length < 1) { + return Logger.error('获取服务端预设维度错误') + } + + // instance.serverDimensions = serverDimensions + SugoIO.store('serverDimensions', serverDimensions) + + this._customProperties.events = res.web_event_bindings || {} + this._customProperties.pageInfo = pageInfo + + // Write in Debugger + Debugger.addBuffer('PageEvents', res.web_event_bindings) + Debugger.addBuffer('PageConfigs', res.page_info) + + var props = this._getDefaultProperties('view') + if (pageInfo.code) { + // 浏览参数设置代码注入 + try { + // 代码注入失败不影响整个上报记录 + var sugo_props = new Function('conf', 'instance', pageInfo.code) + var custom_props = sugo_props(pageInfo, instance) || {} + if (_.isObject(custom_props)) { + _.extend(props, custom_props) + } + } catch (e) { + Logger.error('sugoio track code err => %s', e.message) + } + } + if (pageInfo.page_name) { + // 写入全局 storage + SugoIO.store('pageInfo', pageInfo) + } + var page_name = (pageInfo.page_name || document.title) + var properties = _.extend({ page_name: page_name }, props) + + // 次访问时间记录 + if (!instance.persistence.props.hasOwnProperty(CONSTANTS.FIRST_VISIT_TIME)) { + var timestamp = _.timestamp() + var tmp = {} + + tmp[CONSTANTS.FIRST_VISIT_TIME] = timestamp + properties = _.extend({}, properties, tmp) + instance.register_once(tmp) + + Logger.info('Track first visit event: %s', timestamp) + + // 上报首次访问之后再上报浏览事件 + var p = _.extend({}, properties, { event_type: 'first_visit' }) + instance.track(CONSTANTS.FIRST_VISIT_EVENT_NAME, p, function () { + instance._loaded() + instance.track('浏览', properties) + }) + } else { + instance._loaded() + instance.track('浏览', properties) + } + + this._addDomEventHandlers(instance) + + } else { + instance.__autotrack_enabled = false + } + }, this) + + const loadSdkDecide = () => { + let url = this._getCurrentUrl(enable_hash) + let path = location.pathname + enable_hash ? location.hash : '' + API.set_host(instance.get_config('app_host')) + // 获取页面分类 + API.getDeployedPageCategories(token, '0').success(_.bind(function (res) { + var categories = res.success ? res.result : [] + Logger.debug('Page categories: %s', categories) + Debugger.addBuffer('PageCategories', categories) + + var regulations = _.map(categories, function (r) { + return r.regulation + }) + var matched = Regulation.exec(url, regulations) + if (matched) { + var record = _.find(categories, function (r) {return r.regulation === matched}) || {} + instance.page_category = record.name + } + + // 加载获取pathname + var separator = '____' + match_pathname(instance.get_config('decide_host'), token, url, path, true, function (err, pathname) { + // 加载事件 + if (err) { + return Logger.error(err) + } + var uri = instance.get_config('decide_host') + '/api/sdk/desktop/decide?' + + 'verbose=true' + + '&version=0' + + '&lib=web' + + '&projectId=' + instance.get_config('project_id') + + '&path_name=' + encodeURIComponent(pathname.join(separator)) + + '&token=' + token + + '&separator=' + separator + + request().send('GET', uri).success(parseDecideResponse) + }) + }, this)) + } + + // vue单页应用hash变化即页面切换,重新加载配置 + if (enable_hash) { + window.addEventListener('hashchange', () => { + setTimeout(() => { + // load server events config + loadSdkDecide() + }, 200) + }, false) + } else { + // load server events config + loadSdkDecide() + } + } + }, + + _getCurrentUrl: function(enable_hash) { + const hash = enable_hash === true ? location.hash : '' + return (location.origin || (location.protocol + '//' + location.host)) + location.pathname + hash + }, + + _editorParamsFromHash: function (instance, state) { + var editorParams + try { + if (_.isString(state)) { + state = JSON.parse(state) + } + var expiresInSeconds = state.expires_in + editorParams = { + 'accessToken': state.access_token, + 'accessTokenExpiresAt': (new Date()).getTime() + (Number(expiresInSeconds) * 1000), + 'projectToken': state.token, + 'projectId': state.project_id, + 'userId': state.user_id, + 'choosePage': state.choose_page + } + window.sessionStorage.setItem('editorParams', JSON.stringify(editorParams)) + + if (state.desiredHash) { + window.location.hash = state.desiredHash + } else if (window.history) { + history.replaceState('', document.title, window.location.pathname + window.location.search) // completely remove hash + } else { + window.location.hash = '' // clear hash (but leaves # unfortunately) + } + } catch (e) { + Logger.error('Unable to parse data from hash', e) + } + return editorParams + }, + + /** + * To load the visual editor, we need an access token and other state. That state comes from one of three places: + * 1. In the URL hash params if the customer is using an old snippet + * 2. From session storage under the key `_sugocehash` if the snippet already parsed the hash + * 3. From session storage under the key `editorParams` if the editor was initialized on a previous page + */ + _maybeLoadEditor: function (instance) { + var win = window + var storage = win.sessionStorage + var hash = '' + var fromHash = false + var fromStorage = storage.getItem('_sugocehash') + var params + + try { + hash = window.atob(win.location.hash.replace('#', '')) + if (hash && _.includes(hash, 'state')) { + fromHash = JSON.parse(hash).state + } + } catch (e) { + // console.log(e) + } + + if (fromHash) { + // happens if they are initializing the editor using an old snippet + params = this._editorParamsFromHash(instance, fromHash) + } else if (fromStorage) { + // happens if they are initialized the editor and using the new snippet + params = this._editorParamsFromHash(instance, fromStorage) + storage.removeItem('_sugocehash') + } else { + // get credentials from sessionStorage from a previous initialzation + params = JSON.parse(storage.getItem('editorParams') || '{}') + } + + if (!params.app_host) { + params.app_host = instance.get_config('app_host') + } + + if (params.projectToken && instance.get_config('token') === params.projectToken) { + this._loadEditor(instance, params) + return true + } + + return false + }, + + // only load the codeless event editor once, even if there are multiple instances of SugoioLib + _editorLoaded: false, + _loadEditor: function (instance, editorParams) { + // TODO css与js一起打包,避免其中某一个加载失败造成的造成的异常 + if (!this._editorLoaded) { + this._editorLoaded = true + var editorUrl + var cacheBuster = '?_ts=' + (new Date()).getTime() + var siteMedia = instance.get_config('app_host') + '/_bc/sugo-sdk-js/libs' + var filename = typeof window.Vue === 'function' ? 'sugo-editor-lite.min.js' : 'sugo-editor.min.js' + if (Config.debug) { + editorUrl = siteMedia + '/' + filename + cacheBuster + } else { + editorUrl = siteMedia + '/' + filename + cacheBuster + } + var that = this + that._loadScript(editorUrl, function () { + setTimeout(function () { + SugoIO.Global.Editor.run(instance, editorParams) + }, 400) + }) + return true + } + return false + }, + + // this is a mechanism to ramp up CE with no server-side interaction. + // when CE is active, every page load results in a decide request. we + // need to gently ramp this up so we don't overload decide. this decides + // deterministically if CE is enabled for this project by modding the char + // value of the project token. + enabledForProject: function (token, numBuckets, numEnabledBuckets) { + numBuckets = !_.isUndefined(numBuckets) ? numBuckets : 10 + numEnabledBuckets = !_.isUndefined(numEnabledBuckets) ? numEnabledBuckets : 10 + var charCodeSum = 0 + for (var i = 0; i < token.length; i++) { + charCodeSum += token.charCodeAt(i) + } + return (charCodeSum % numBuckets) < numEnabledBuckets + }, + + isBrowserSupported: function () { + // TODO IE8+ 判断 + return typeof document.querySelectorAll !== void 0 + } +} + +_.bind_instance_methods(track) +_.safewrap_instance_methods(track) + +module.exports = { + DISABLE_COOKIE: DISABLE_COOKIE, + track: track +} diff --git a/src/sugoio/default-config.js b/src/sugoio/default-config.js new file mode 100644 index 0000000..202b0f5 --- /dev/null +++ b/src/sugoio/default-config.js @@ -0,0 +1,42 @@ +/** + * @Author sugo.io + * @Date 17-11-18 + */ + +/** @type {SugoIOSDKJSConfig} */ +var Config = require('../config') +var HTTP_PROTOCOL = (('https:' === document.location.protocol) ? 'https://' : 'http://') + +/** @type {SugoIOConfigInterface} */ +var DEFAULT_CONFIG = { + api_host: HTTP_PROTOCOL + Config.api_host, //HTTP_PROTOCOL + 'api.mixpanel.com', + app_host: HTTP_PROTOCOL + Config.app_host, //HTTP_PROTOCOL + 'mixpanel.com', + decide_host: HTTP_PROTOCOL + Config.decide_host, + autotrack: true, + cdn: HTTP_PROTOCOL + Config.app_host, + encode_type: Config.encode_type || 'plain', // json/plain + dimensions: Config.dimensions || {}, + cross_subdomain_cookie: true, + persistence: 'cookie', + persistence_name: '__persistence__sugoio', + cookie_name: '__cookie__', + loaded: function () {}, + store_google: true, + save_referrer: true, + test: false, + verbose: false, + img: false, + track_pageview: false, + debug: false, + track_links_timeout: 300, + cookie_expiration: 365, + upgrade: false, + disable_persistence: false, + disable_cookie: false, + secure_cookie: false, + ip: true, + property_blacklist: [], + enable_hash: false // 单页应用页面设置开启hash配置 +} + +module.exports = DEFAULT_CONFIG diff --git a/src/sugoio/dom.js b/src/sugoio/dom.js new file mode 100644 index 0000000..e99a0c3 --- /dev/null +++ b/src/sugoio/dom.js @@ -0,0 +1,304 @@ +/** + * Created by coin on 30/12/2016. + */ + +var exports = module.exports +var _ = require('../utils')._ + +var tryCatch = _.tryCatch +var doc = document +var body = doc.body +var Node = _.isBrowser() ? window.Node : {} + +function $ (selector, context) { + return (context || body).querySelector(selector) +} + +function $$ (selector, context) { + return (context || body).querySelectorAll(selector) +} + +function display (elem, show) { + return tryCatch(function () { + elem.style.display = show ? 'block' : 'none' + return elem + })() +} + +function show (elem) { + return display(elem, true) +} + +function hide (elem) { + return display(elem, false) +} + +function html (elem, html) { + var length = arguments.length + return tryCatch(function () { + if (length === 1) { + return elem.innerHTML + } else if (length === 2) { + elem.innerHTML = html + return elem + } + return elem + })() +} + +function text (elem, text) { + var length = arguments.length + return tryCatch(function () { + if (length === 1) { + return elem.innerText + } else if (length === 2) { + elem.innerText = text + return elem + } + return elem + })() +} + +function style (elem, style) { + return tryCatch(function () { + _.each(style, function (val, name) { + elem.style[name] = _.isNumber(val) ? val + 'px' : val + }) + })() +} + +function addClass (elem, name) { + return tryCatch(function () { + if (elem.classList) { + elem.classList.add(name) + } else { + var classNames = elem.className + if (classNames === '') { + elem.className = name + } else { + var list = classNames.split(/\u0020/) + if (list.indexOf(name) === -1) { + list.push(name) + elem.className = list.join(' ') + } + } + } + return elem + })() +} + +function removeClass (elem, name) { + return tryCatch(function () { + if (elem.classList) { + elem.classList.remove(name) + } else { + var classNames = elem.className + if (classNames !== '') { + var list = classNames.split(/\u0020/) + var i = list.indexOf(name) + if (i !== -1) { + list.splice(i, 1) + elem.className = list.join(' ') + } + } + } + return elem + })() +} + +function getBoundingClientRect (elem) { + return tryCatch(function () { + var attr = ['bottom', 'height', 'left', 'right', 'top', 'width'] + var rect = elem.getBoundingClientRect() || {} + var result = {} + attr.forEach(function (name) { + result[name] = rect[name] === void 0 ? -99999 : rect[name] + }) + return result + })() +} + +function _getElementBounds (el) { + var bounds + + // a lot of people use anchor tags to wrap children and make them all linkable + // (for an example look at the video tiles on youtube.com home page) + // since anchor tags are display inline by default, we want to calculate the bounds + // of all of its children. however, we only want to do that when all of the direct + // children are element nodes (and not text nodes + other children like we do often + // for icons + text) + var allChildrenAreElements = el.children.length === el.childNodes.length + if (el.tagName.toLowerCase() === 'a' && el.children.length > 0 && allChildrenAreElements) { + Array.from(el.children) + .filter(function (child) { // filter out elements that are hidden or inline + var styles = getComputedStyle(child) + return !(child.tagName === 'a' || styles.display === 'none') + }) + .forEach(function (child) { + var b = child.getBoundingClientRect() + if (!bounds) { + bounds = { + top: b.top, + right: b.right, + bottom: b.bottom, + left: b.left + } + } + if (b.top < bounds.top) { + bounds.top = b.top + } + if (b.right > bounds.right) { + bounds.right = b.right + } + if (b.bottom > bounds.bottom) { + bounds.bottom = b.bottom + } + if (b.left < bounds.left) { + bounds.left = b.left + } + }) + } else { + bounds = el.getBoundingClientRect() + } + + if (!bounds) { + bounds = { + top: 0, + right: 0, + bottom: 0, + left: 0 + } + } + return bounds +} + +/** + * @param el + * @return {{height: number, left: number, top: number, width: number, right: number, bottom: number}} + */ +function getAbsoluteBoundingClientRect (el) { + var bb = { + height: 0, + left: 0, + top: 0, + width: 0, + right: 0, + bottom: 0 + } + var BORDER_WIDTH = 2 + if (el) { + var bounds = _getElementBounds(el) + Object.assign(bb, { + height: bounds.bottom - bounds.top + (BORDER_WIDTH * 2), + left: (bounds.left + (window.scrollX || window.pageXOffset) - BORDER_WIDTH), + top: (bounds.top + (window.scrollY || window.pageYOffset) - BORDER_WIDTH), + width: bounds.right - bounds.left + (BORDER_WIDTH * 2), + bottom: bounds.bottom + (window.scrollY || window.pageYOffset) - BORDER_WIDTH, + right: bounds.right + (window.scrollX || window.pageXOffset) - BORDER_WIDTH + }) + } + + return bb +} + +function remove (elem, child) { + return tryCatch(function () { + elem.removeChild(child) + return elem + })() +} + +function append (elem, child) { + return tryCatch(function () { + elem.appendChild(child) + return elem + })() +} + +function attr (elem, name, value) { + return tryCatch(function () { + if (value === void 0) + return elem.getAttribute(name) + return elem.setAttribute(name, value) + })() +} + +function isNode (any) { + var result = false + tryCatch(function () { + result = any.nodeType === Node.ELEMENT_NODE + })() + return result +} + +function shiftHostPageDown (hostElements) { + var HEADER_HEIGHT = 65 + // The goal is to move the entire page down by the height of the control bar + // so that it doesn't cover anything. For pages with no absolute or fixed positioning, + // padding works fine. However, we also need to adjust for absolutely positioned elements + // + // NOTE: This should be executed before we load the editor since it iterates all elements on the page + // update body padding to allow room for the header + // var bodyCss = getComputedStyle(document.body) + // var newPaddingTop = (parseInt(bodyCss['padding-top'], 10) || 0) + HEADER_HEIGHT + // document.body.style.cssText += '; padding-top:' + newPaddingTop + 'px !important; transition: padding 300ms cubic-bezier(0, 0, 0, 0.97);' + + var hasAncestor = function hasAncestor (el, cssProp, vals) { + for (var curEl = el.parentNode; curEl.parentNode; curEl = curEl.parentNode) { + var parentCss = getComputedStyle(curEl) + if ((vals.includes && vals.includes(parentCss[cssProp])) || _.includes(vals, parentCss[cssProp])) { + return true + } + } + return false + } + + Array.from(hostElements).forEach(function (el) { + var css = getComputedStyle(el) + if (css.position === 'fixed' || css.position === 'absolute' && !hasAncestor(el, 'position', ['absolute', 'fixed', 'relative'])) { + var origBodyStyles = document.body.style.cssText + var origElBounds = el.getBoundingClientRect() + document.body.style['padding-top'] = (parseInt(document.body.style['padding-top'], 10) || 0) + 1 + 'px' + var newElBounds = el.getBoundingClientRect() + document.body.style.cssText = origBodyStyles + if (origElBounds.top === newElBounds.top) { + var newTop = (parseInt(css.top, 10) || 0) + HEADER_HEIGHT + el.style.cssText += '; top:' + newTop + 'px !important; transition: top 500ms ease-out;' + } + } + }) +} + +/** + * @param {number} position + * @return {number} + * @see https://dom.spec.whatwg.org/#dom-document-compatmode + * @see https://drafts.csswg.org/cssom-view/#dom-element-scrolltop + */ +function scrollToTop (position) { + if (document.compatMode === 'CSS1Compat') { + return document.documentElement.scrollTop = position + } + return document.body.scrollTop = position +} + +exports.doc = doc +exports.body = body +exports.$ = $ +exports.$$ = $$ +exports.display = display +exports.show = show +exports.hide = hide +exports.html = html +exports.text = text +exports.style = style +exports.addClass = addClass +exports.removeClass = removeClass +exports.getBoundingClientRect = getBoundingClientRect +exports.getAbsoluteBoundingClientRect = getAbsoluteBoundingClientRect +exports.remove = remove +exports.append = append +exports.attr = attr +exports.isNode = isNode +exports.shiftHostPageDown = shiftHostPageDown +exports.scrollToTop = scrollToTop diff --git a/src/sugoio/entry.js b/src/sugoio/entry.js new file mode 100644 index 0000000..185d2ed --- /dev/null +++ b/src/sugoio/entry.js @@ -0,0 +1,104 @@ +/** + * @Author sugo.io + * @Date 17-11-18 + */ + +var _ = require('sugo-sdk-js-utils')['default'] +var CONSTANTS = require('../../constants') +var Sugoio = require('./Sugoio') +var Global = require('../global') +var onDOMContentLoaded = require('./on-dom-content-loaded') +var Logger = require('../logger').get('Sugoio') + + +/** @typedef {object} */ +var instances = {} + +function override_mp_init_func () { + /** + * @param {string} [token] + * @param {object} [config] + * @param {string} [name] + * @return {Sugoio} + */ + Global.get().init = function (token, config, name) { + var master = Global.get() + if (name) { + // 如果传入name,则表示初始化一个子实例 + // 并将其挂在主实例上 + if (!master[name]) { + master[name] = instances[name] = Sugoio.create_sugoio(token, config, name) + } + return master[name] + } else { + var instance + // 没有name则初始化主实例 + if (instances[CONSTANTS.PRIMARY_INSTANCE_NAME]) { + // 已经初始化直接返回 + instance = instances[CONSTANTS.PRIMARY_INSTANCE_NAME] + } else if (token) { + // 初始化主实例 + instance = Sugoio.create_sugoio(token, config, CONSTANTS.PRIMARY_INSTANCE_NAME) + instances[CONSTANTS.PRIMARY_INSTANCE_NAME] = instance + } + + instance._ = _ + Global.set(instance) + Logger.info('%s initialized', instance.name) + return instance + } + } +} + +function init_from_snippet () { + var master = Global.get() + + // Initialization + if (_.isUndefined(master)) { + // sugoio wasn't initialized properly, report error and quit + return Logger.error('"sugoio" object not initialized. Ensure you are using the latest version of the Sugoio JS Library along with the snippet we provide.') + } + + if (master.__loaded || (master.config && master.persistence)) { + // lib has already been loaded at least once; we don't want to override the global object this time so bomb early + return Logger.error('Sugoio library has already been downloaded at least once.') + } + + var snippet_version = master.__SV || 0 + if (snippet_version < 1.1) { + // sugoio wasn't initialized properly, report error and quit + return Logger.error('Version mismatch; please ensure you\'re using the latest version of the Sugoio code snippet.') + } + + // Load instances of the Sugoio Library + _.each(master._i, function (item) { + if (item && _.isArray(item)) { + var name = item[item.length - 1] + if (instances.hasOwnProperty(name)) { + return Logger.error('Project '.concat(name).concat(' was initialized!')) + } + instances[name] = Sugoio.create_sugoio.apply(this, item) + } + }) + + override_mp_init_func() + master.init() + + // Fire loaded events after updating the window's sugoio object + // 此时并没有loaded,只有在接口数据完全返回后才是loaded状态 + // 所以需要放在track中调用 + // _.each(instances, function (instance) { + // instance._loaded() + // }) + + // 在DOMContentLoaded之后触发所有实例的 _dom_loaded 方法 + onDOMContentLoaded(function () { + _.each(instances, function (ins) { + ins._dom_loaded() + }) + }) +} + +module.exports = { + init_from_snippet: init_from_snippet +} diff --git a/src/sugoio/events.js b/src/sugoio/events.js new file mode 100644 index 0000000..c7a6bef --- /dev/null +++ b/src/sugoio/events.js @@ -0,0 +1,166 @@ +/** + * Created by coin on 30/12/2016. + */ + +var exports = module.exports +var _ = require('../utils')._ +var doc = require('./dom').doc + +function uuid () { + return 'sugo_' + _.UUID().replace(/-/g, '_') +} + +var MARK = { LISTENER: uuid() } +var CssSelectorType = { + ID: /(?:^#\w+$)/, + // .a 或 .a.b.. + CLASS: /(?:\.\w+)+/, + // tagName + TAG: /(?:[a-zA-z]+)/ +} + +var Hooks = { + type: function (selector) { + var type = null + _.each(CssSelectorType, function (r, t) { + return r.test(selector) && (type = t) + }) + return type + }, + + isSelector: function (node, selector) { + if (!selector) { + return true + } + var handle = Hooks.hooks[this.type(selector)] + return _.isFunction(handle) ? handle(node, selector) : true + }, + + hooks: { + /** + * @private + * @return {boolean} + */ + ID: function (node, str) { + return node.getAttribute('id') === str + }, + + /** + * @private + * @return {boolean} + */ + CLASS: function (node, str) { + var classList = node.classList + if (!classList) { + classList = node.getAttribute('class') + classList = classList ? classList.split(' ') : [] + } + + var matchClassNames = str.split('.').splice(1) + + return matchClassNames.some(function (className) { + return classList.indexOf(className) !== -1 + }) + }, + + /** + * @private + * @return {boolean} + */ + TAG: function (node, str) { + return node.tagName.toLocaleLowerCase() === str.toLocaleLowerCase() + } + } +} + +function on_base (node, type, handle, capture) { + node.addEventListener(type, handle, !!capture) + return node +} + +function off_base (node, type, handle, capture) { + node.removeEventListener(type, handle, !!capture) + return node +} + +function on (node, type, selector, handle, data) { + const listener_attr = node[MARK.LISTENER] || (node[MARK.LISTENER] = {}) + const listener = listener_attr[type] || (listener_attr[type] = []) + + listener.push(handle) + + on_base(node, type, function (e) { + var target = closest(e.target, selector) + if (target) { + handle.call(target, e, target, data) + } + }, false) +} + +function off (node, type, handle) { + const listener_attr = node[MARK.LISTENER] || (node[MARK.LISTENER] = {}) + + // off() + if (arguments.length === 0) { + type = handle = null + } + // off('click') + else if (arguments.length === 1) { + handle = null + } + + if (type === null) { + _.each(listener_attr, function (_type, handles) { + _.each(handles, function (_handle) { + off(node, _type, _handle, false) + }) + }) + } else { + if (handle === null) { + handle = listener_attr[type] + } else { + handle = _.isArray(handle) ? handle : [handle] + } + + _.each(handle, function (handle) { + off_base(node, type, handle, false) + }) + } +} + +function closest (node, selector) { + + if (!selector) { + return node + } + + if (node.closest) { + return node.closest(selector) + } + + while (node !== doc && !Hooks.isSelector(node, selector)) { + node = node.parentNode + } + + return node === doc ? null : node +} + +/** + * from jquery-1.9.1 + */ +function contains (a, b) { + var c = a.nodeType === 9 ? a.documentElement : a + + return a === b || !!(b && b.nodeType === 1 && ( + c.contains + ? c.contains(b) + : a.compareDocumentPosition && a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_CONTAINED_BY + )) +} + +exports.on_base = on_base +exports.off_base = off_base +exports.on = on +exports.off = off +exports.closest = closest +exports.contains = contains diff --git a/src/sugoio/interfaces.js b/src/sugoio/interfaces.js new file mode 100644 index 0000000..504077e --- /dev/null +++ b/src/sugoio/interfaces.js @@ -0,0 +1,89 @@ +/** + * @Author sugo.io + * @Date 17-11-17 + */ + +/** + * @typedef {object} SugoIOConfigInterface + * @property {string} [api_host] + * @property {string} [app_host] + * @property {string} [decide_host] + * @property {boolean} [autotrack] + * @property {string} [cdn] + * @property {string} [encode_type] // enum { json, plain } + * @property {object} [dimensions] + * @property {boolean} [cross_subdomain_cookie] + * @property {string} [persistence] // enum { cookie, localStorage } + * @property {string} [persistence_name] // 持久化数据key + * @property {string} [cookie_name] + * @property {function} [loaded] + * @property {boolean} [store_google] + * @property {boolean} [save_referrer] + * @property {boolean} [test] + * @property {boolean} [verbose] + * @property {boolean} [img] + * @property {boolean} [track_pageview] + * @property {boolean} [debug] + * @property {number} [track_links_timeout] + * @property {number} [cookie_expiration] + * @property {boolean} [upgrade] + * @property {boolean} [disable_persistence] + * @property {boolean} [disable_cookie] + * @property {boolean} [secure_cookie] + * @property {boolean} [ip] + * @property {string[]} [property_blacklist] + * + * @property {string} [token] + * @property {string} [project_id] + * @property {string} [name] + */ + + +/** + * @typedef {object} SugoIOInstanceParams + * @property {string} token + * @property {string} name + * @property {SugoIOConfigInterface} config + */ + +/** + * @typedef {Array} SugoIORawMasterPeople + * @property {function} toString + */ + +/** + * People shallow methods + * @typedef {object} SugoIOPeopleShallow + * @property {function} set + * @property {function} set_once + * @property {function} increment + * @property {function} append + * @property {function} union + * @property {function} track_charge + * @property {function} clear_charges + * @property {function} delete_user + */ + +/** + * 未初始化的SugoIO主对象 + * @typedef {Array} SugoIORawMaster + * @property {Array} _i + * @property {number} _SV + * @property {SugoIORawMasterPeople} people + * @property {function(no_stub:boolean)} toString + * + * + * shallow methods for Sugoio & People + * @property {function} time_event + * @property {function} track + * @property {function} track_pageview + * @property {function} track_links + * @property {function} track_forms + * @property {function} register + * @property {function} register_once + * @property {function} unregister + * @property {function} name_tag + * @property {function} set_config + * @property {function} reset + * @property {SugoIOPeopleShallow} people + */ diff --git a/src/sugoio/on-dom-content-loaded.js b/src/sugoio/on-dom-content-loaded.js new file mode 100644 index 0000000..9876219 --- /dev/null +++ b/src/sugoio/on-dom-content-loaded.js @@ -0,0 +1,57 @@ +/** + * @Author sugo.io + * @Date 17-11-18 + */ + +var _ = require('sugo-sdk-js-utils')['default'] + +function doScrollCheck (callback) { + try { + document.documentElement.doScroll('left') + } catch (e) { + return setTimeout(function () { + doScrollCheck(callback) + }, 100) + } +} + +module.exports = function (callback) { + + function handle () { + if (handle.done) { + return + } + handle.done = true + callback() + } + + if (document.addEventListener) { + if (document.readyState === 'complete') { + // safari 4 can fire the DOMContentLoaded event before loading all + // external JS (including this file). you will see some copypasta + // on the internet that checks for 'complete' and 'loaded', but + // 'loaded' is an IE thing + handle() + } else { + document.addEventListener('DOMContentLoaded', handle, false) + } + } else if (document.attachEvent) { + // IE + document.attachEvent('onreadystatechange', handle) + + // check to make sure we arn't in a frame + var toplevel = false + try { + toplevel = window.frameElement === null + } catch (e) { + // noop + } + + if (document.documentElement.doScroll && toplevel) { + doScrollCheck(handle) + } + } + + // fallback handler, always will work + _.register_event(window, 'load', handle, true) +} \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..2c65447 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,20 @@ +/** + * @Author sugo.io + * @Date 17-11-14 + */ +const utils = require('sugo-sdk-js-utils') + +/** + * 获取当前页面路径 + * @param {*} enable_hash + */ +const getCurrentUrl = (enable_hash) => { + const hash = enable_hash === true ? location.hash : '' + return (location.origin || (location.protocol + '//' + location.host)) + location.pathname + hash +} + +module.exports = { + _: utils['default'], + getCurrentUrl, + userAgent: window.navigator.userAgent +} diff --git a/src/wx-mini.program.js b/src/wx-mini.program.js new file mode 100644 index 0000000..d2da42e --- /dev/null +++ b/src/wx-mini.program.js @@ -0,0 +1,489 @@ +// 微信小程序sdk上报 + +let GlOBAL_CONFIG = { + project_id: '', // 项目ID + token: '', // 项目token + api_host: '', // 拉取维度服务器地址 + gateway_host: '', // 数据上报网关地址 + prefix: '_sugoio_', + dimPrefix: '_sugoio_dimensions_', + version: '1.0.0', + track_share_app: false, // 转发 + track_pull_down_fresh: false, // 下拉 + track_reach_bottom: false, // 上拉 + track_auto_duration: true, + debug: false +} + +const SUGO_EVENT_TIME_KEY = 'sugo_event_time' +const SUGO_REGISTER_KEY = 'sugo_register' + +function getNetworkType(callbak) { + wx.getNetworkType({ + success: function (res) { + callbak(res.networkType) + } + }) +} + +function getSystemInfo() { + const info = wx.getSystemInfoSync() + return { + device_brand: encodeURIComponent(info.brand), + device_model: encodeURIComponent(info.model), + system_name: encodeURIComponent(info.platform), + system_version: info.system, + app_name: 'wx-mini-program', + app_version: info.version, + screen_dpi: info.pixelRatio, + // screen_height: info.screenHeight, + // screen_width: info.screenWidth + screen_pixel: `${info.screenWidth}*${info.screenHeight}` + } +} + +function getUID() { + try { + return wx.getStorageSync(GlOBAL_CONFIG.prefix + 'auid') + } catch (e) { + // console.log(e) + } +} + +function setUID() { + try { + const uid = sugoio._.shortid() + wx.setStorageSync(GlOBAL_CONFIG.prefix + 'auid', uid) + return uid + } catch (e) { + // console.log(e) + } +} + +function getPagePath() { + try { + let pages = getCurrentPages(), path = '/' + 0 < pages.length && (path = pages.pop().__route__) + return path + } catch (e) { + console.log('get current page path error:' + e) + } +} + +function timestamp() { + Date.now = Date.now || function () { + return +new Date() + } + return Date.now() +} + +function getMainInfo() { + let info = { + //page_name + //path_name + //event_id + //event_name + //event_type + //session_id + event_time: timestamp(), + sugo_lib: 'wechat.mini', + // current_url: getPagePath(), + path_name: getPagePath() + } + info.distinct_id = function () { + let uid = getUID() + uid || (uid = setUID()) + return uid + }() + return info +} + +function getBasicInfo() { + const info = getSystemInfo() + getNetworkType(function (network) { + try { + wx.setStorageSync(GlOBAL_CONFIG.prefix + 'network', network) + } catch (e) { + // console.log(e) + } + }) + info.network = wx.getStorageSync(GlOBAL_CONFIG.prefix + 'network') || '4g' + return info +} + +function getExtentInfo() { + let userInfo = sugoio.Data.userInfo + let infos = [], key + for (key in userInfo) userInfo.hasOwnProperty(key) && infos.push(key + '=' + userInfo[key]) + userInfo = infos.join(';') + return { + app_name: 'wx' //, + // ext: 'v=' + DEF_CONFIG.version + (null !== userInfo && '' !== userInfo ? ';ui=' + encodeURIComponent(userInfo) : '') + } +} + +function setServerDimensions() { + let uri = GlOBAL_CONFIG.api_host + '/api/sdk-wx-mini/dimensions?' + + 'app_version=' + GlOBAL_CONFIG.app_version + + '&project_id=' + GlOBAL_CONFIG.project_id + + '&token=' + GlOBAL_CONFIG.token + wx.request({ + url: uri, + success: function (res) { + try { + wx.setStorageSync(GlOBAL_CONFIG.dimPrefix + GlOBAL_CONFIG.token, res.data.result) + } catch (e) { + // console.log(e) + } + } + }) +} + +function getServerDimensions() { + try { + return wx.getStorageSync(GlOBAL_CONFIG.dimPrefix + GlOBAL_CONFIG.token) + } catch (e) { + // console.log(e) + } +} + +function removeEventTime(eventName) { + try { + let eventTimeObj = wx.getStorageSync(SUGO_EVENT_TIME_KEY) + const res = eventTimeObj ? eventTimeObj[eventName] : 0 + if (res) { + delete eventTimeObj[eventName] + wx.setStorageSync(SUGO_EVENT_TIME_KEY, eventTimeObj) + } + return res || '' + } catch (e) { + // console.log(e) + } +} + +function setRegisterObj(obj, once) { + let oldRegisterObj = wx.getStorageSync(SUGO_REGISTER_KEY) + let newRegisterObj = {} + if (once) { + newRegisterObj = { + ...obj, + ...oldRegisterObj + } + } else { + newRegisterObj = { + ...oldRegisterObj, + ...obj + } + } + try { + wx.setStorageSync(SUGO_REGISTER_KEY, newRegisterObj) + } catch (e) { + // console.log(e) + } +} + +function getRegisterObj() { + try { + return wx.getStorageSync(SUGO_REGISTER_KEY) + } catch (e) { + // console.log(e) + } +} + +function removeRegister(key) { + let oldRegisterObj = wx.getStorageSync(SUGO_REGISTER_KEY) + if (oldRegisterObj[key]) { + delete oldRegisterObj[key] + try { + wx.setStorageSync(SUGO_REGISTER_KEY, oldRegisterObj) + } catch (e) { + // console.log(e) + } + } +} + +const sugoio = { + _: { + isArray: Array.isArray || function (obj) { + return Object.prototype.toString.call(obj) === '[object Array]' + }, + isObject: (obj) => { + return (Object.prototype.toString.call(obj) === '[object Object]') && (obj != null) + }, + has: (obj, key) => { + // obj 不能为 null 或者 undefined + return obj !== null && hasOwnProperty.call(obj, key) + }, + shortid: () => { + let d = new Date().getTime() + const result = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (d + Math.random() * 16) % 16 | 0 + d = Math.floor(d / 16) + return (c === 'x' ? r : (r & 0x7 | 0x8)).toString(16) + }) + return result + }, + encode: (dimensions, serverDimensions) => { + if (!serverDimensions || serverDimensions.length < 1) { + return null + } + const EVENTS_MAPS = { + click: '点击', + change: '改变', + // submit: '提交', + focus: '对焦', + view: '浏览', + duration: '停留', + pageloading: '加载', + first_visit: '首次访问', + first_login: '首次登录' + } + const HEADER_SPLIT = '\x02' //\002 + const CONTENT_SPLIT = '\x01' //\001 + const DRUID_COLUMN_TYPE = { 0: 'l', 1: 'f', 2: 's', 3: 's', 4: 'd', 5: 'i', 6: 's', 7: 'f', 8: 'f' } + let keys = [], vals = [], removeDimensions = [] + for (const dim in dimensions) { + const exists = serverDimensions.find(function (ser) { return ser.name === dim }) + if (exists) { + keys.push(DRUID_COLUMN_TYPE[exists.type] + '|' + dim) + const val = dim === 'event_type' ? EVENTS_MAPS[dimensions[dim]] : dimensions[dim] + vals.push(val) + } else { + removeDimensions.push(dim) + } + } + return { + data: keys.join(',') + HEADER_SPLIT + vals.join(CONTENT_SPLIT), + removeDimensions + } + }, + utf8Encode: (string) => { + string = (string + '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') + var utftext = '', start, end + var stringl = 0, n + start = end = 0 + stringl = string.length + for (n = 0; n < stringl; n++) { + var c1 = string.charCodeAt(n) + var enc = null + if (c1 < 128) { + end++ + } else if ((c1 > 127) && (c1 < 2048)) { + enc = String.fromCharCode((c1 >> 6) | 192, (c1 & 63) | 128) + } else { + enc = String.fromCharCode((c1 >> 12) | 224, ((c1 >> 6) & 63) | 128, (c1 & 63) | 128) + } + if (enc !== null) { + if (end > start) { + utftext += string.substring(start, end) + } + utftext += enc + start = end = n + 1 + } + } + if (end > start) { + utftext += string.substring(start, string.length) + } + return utftext + }, + base64Encode: (data) => { + var b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' + var o1, o2, o3, h1, h2, h3, h4, bits, i = 0, ac = 0, enc = '', tmp_arr = [] + if (!data) { + return data + } + data = sugoio._.utf8Encode(data) + do { // pack three octets into four hexets + o1 = data.charCodeAt(i++) + o2 = data.charCodeAt(i++) + o3 = data.charCodeAt(i++) + bits = o1 << 16 | o2 << 8 | o3 + h1 = bits >> 18 & 0x3f + h2 = bits >> 12 & 0x3f + h3 = bits >> 6 & 0x3f + h4 = bits & 0x3f + // use hexets to index into b64, and append result to encoded string + tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4) + } while (i < data.length) + enc = tmp_arr.join('') + switch (data.length % 3) { + case 1: + enc = enc.slice(0, -2) + '==' + break + case 2: + enc = enc.slice(0, -1) + '=' + break + } + return enc + } + }, + App: { + init: (opts) => { + if (!('api_host' in opts) || !('gateway_host' in opts) || !opts.project_id || !opts.token) { + throw new Error('project_id, token, api_host, gateway_host为必设参数.') + } + const systemInfo = getSystemInfo() + GlOBAL_CONFIG = { + ...GlOBAL_CONFIG, + ...opts, + app_version: systemInfo.app_version + } + setServerDimensions() + } + }, + Page: { + init: () => { + setServerDimensions() + const pages = getCurrentPages() + const currentPage = pages[pages.length - 1] + currentPage.onShow && !function () { + var func = currentPage.onShow + currentPage.onShow = function () { + sugoio.track('浏览', { + // current_url: currentPage.__route__, + event_type: 'view' + }) + if (GlOBAL_CONFIG.track_auto_duration) { + sugoio.time_event('停留') + } + func.call(this, arguments) + } + }() + + GlOBAL_CONFIG.track_auto_duration && currentPage.onHide && !function () { + const func = currentPage.onHide + currentPage.onHide = function () { + sugoio.track('停留', {}) + func.call(this, arguments) + } + }() + + GlOBAL_CONFIG.track_pull_down_fresh && currentPage.onPullDownRefresh && ! function () { + const func = currentPage.onPullDownRefresh + currentPage.onPullDownRefresh = function () { + sugoio.track(GlOBAL_CONFIG.prefix + 'pulldownfresh', { + // current_url: currentPage.__route__, + event_type: 'onPullDownRefresh' + }) + func.call(this, arguments) + } + }() + GlOBAL_CONFIG.track_reach_bottom && currentPage.onReachBottom && ! function () { + const func = currentPage.onReachBottom + currentPage.onReachBottom = function () { + sugoio.track(GlOBAL_CONFIG.prefix + 'reachbottom', { + // current_url: currentPage.__route__, + event_type: 'onReachBottom' + }) + func.call(this, arguments) + } + }() + GlOBAL_CONFIG.track_share_app && currentPage.onShareAppMessage && ! function () { + const func = currentPage.onShareAppMessage + currentPage.onShareAppMessage = function () { + sugoio.track(GlOBAL_CONFIG.prefix + 'shareapp', { + // current_url: currentPage.__route__, + event_type: 'onShareAppMessage' + }) + return func.call(this, arguments) + } + }() + } + }, + track: (event_name, properties, callback) => { + let results = [], mainInfo = getMainInfo(), extInfo = getExtentInfo(), basicInfo = getBasicInfo() + mainInfo.event_type = properties.event_type || 'click' + mainInfo.event_name = event_name + mainInfo.event_id = GlOBAL_CONFIG.event_id || sugoio._.shortid() + mainInfo.token = GlOBAL_CONFIG.token + let props = 'undefined' === typeof properties ? {} : properties + for (const k in props) { + mainInfo[k] = props[k] + } + for (const k in extInfo) { + mainInfo[k] = extInfo[k] + } + for (const k in basicInfo) { + mainInfo[k] = basicInfo[k] + } + + // 处理停留时间 + var start_timestamp = removeEventTime(event_name) + if (start_timestamp) { + const duration_in_ms = new Date().getTime() - start_timestamp + mainInfo.duration = parseFloat((duration_in_ms / 1000).toFixed(2)) + mainInfo.event_type = 'duration' //增加停留事件类型 + } + + // 处理register + const registerObj = getRegisterObj() + if (registerObj) { + mainInfo = { + ...mainInfo, + ...registerObj + } + } + + results = [ + 'locate=' + GlOBAL_CONFIG.project_id, + 'token=' + GlOBAL_CONFIG.token + ] + const serverDimensions = getServerDimensions() + if (!serverDimensions) { + console.log('track faild: no server dimenions') + return + } + const res = sugoio._.encode(mainInfo, serverDimensions) + if (!res) { + console.log('track failed: empty encode data') + return + } + + if (GlOBAL_CONFIG.debug) { + console.log('reportData:', mainInfo) + } + + const data = sugoio._.base64Encode(res.data) + if (res.removeDimensions.length) { + console.log('warn: excluded dimensions =>', res.removeDimensions) + } + + wx.request({ + url: `${GlOBAL_CONFIG.gateway_host}/post?${results.join('&')}`, + method: 'post', + data, + header: { + 'content-type': 'text/plain;charset=UTF-8' + }, + complete: function () { + if (callback) { + callback() + } + } + }) + }, + Data: { + userInfo: null, + lanchInfo: null + }, + time_event: (eventName) => { + try { + return wx.setStorageSync(SUGO_EVENT_TIME_KEY, { + [eventName]: new Date().getTime() + }) + } catch (e) { + // console.log(e) + } + }, + register_once: (obj) => { + setRegisterObj(obj, true) + }, + register: (obj) => { + setRegisterObj(obj, false) + }, + unregister: (key) => { + removeRegister(key) + } +} + +module.exports = sugoio diff --git a/sugoio-jslib-snippet.js b/sugoio-jslib-snippet.js new file mode 100644 index 0000000..93a2a87 --- /dev/null +++ b/sugoio-jslib-snippet.js @@ -0,0 +1,225 @@ +/*! + * sugo-sdk-javascript JavaScript Lib v1.3.13 + * http://sugo.io + * Date: 2017-11-28 + */ + +var SUGOIO_LIB_URL = '//localhost:8000/_bc/sugo-sdk-js/libs/sugoio-latest.min.js'; + +(function (document, sugoio) { + // Only stub out if this is the first time running the snippet. + if (sugoio.__SV) return + + var win = window + var script + var first_script + var functions + var lib_name = 'sugoio' + + win[lib_name] = sugoio + + try { + store(win.location.hash.replace('#', '')) + } catch (e) { + } finally { + capture(sugoio) + } + + /** + * 保存参数到session + * 此处代码不必支持ie8 + * @param {string} base64 + */ + function store (base64) { + var str = win.atob(base64) + var json = JSON.parse(str) + var state = json.state + var params = { + accessToken: state.access_token, + accessTokenExpiresAt: Date.now() + Number(state.expires_in) * 1000, + projectToken: state.token, + projectId: state.project_id, + userId: state.user_id, + choosePage: state.choose_page + } + // 存入参数 + win.sessionStorage.setItem('editorParams', JSON.stringify(params)) + + // 还原用户原有hash + if (state['hash']) { + win.location.hash = state['hash'] + } else if (win.history) { + win.history.replaceState('', document.title, win.location.pathname + win.location.search) // completely remove hash + } else { + win.location.hash = '' // clear hash (but leaves # unfortunately) + } + } + + /** + * 兼容ie事件绑定 + * @param {Element} target + * @param {string} type + * @param {Function} handle + */ + function listen (target, type, handle) { + if (typeof target.addEventListener === 'function') { + return target.addEventListener(type, handle, true) + } + return target.attachEvent('on' + type, handle) + } + + /** + * @param {Array} arr + * @param {Function} iterator + */ + function forEach (arr, iterator) { + for (var i = 0, len = arr.length; i < len; i++) { + iterator(arr[i], i, arr) + } + } + + /** + * 代理所有事件到document上,以便埋点代码控制 + * 埋点代碼可以重寫hooks[name] + * 为了优先级,一律使用捕获模型 + * @param {Object} target + * @example + * sugoio.proxy('focus', function(event){ + * if (event.target is SDK children) { + * event.stopPropagation() + * } + * }) + * + * sugoio.off('focus', function reference) + */ + function capture (target) { + var hooks = {} + var doc = document + var events = ('blur focus focusin focusout load resize scroll unload click dblclick ' + + 'mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave ' + + 'change select submit keydown keypress keyup error contextmenu').split(' ') + + target.proxy = { + proxy: proxy, + off: off + } + + forEach(events, function (type) { + hooks[type] = [] + listen(doc, type, function (event) { + try { + forEach(hooks[type], function (handle) { + handle(event) + }) + } catch (e) { + if (console && typeof console.error === 'function') { + console.error(e.stack) + } + } + }) + }) + + /** + * 代理事件 + * @param {string} type + * @param {Function} handle + */ + function proxy (type, handle) { + var listeners = hooks[type] || (hooks[type] = []) + listeners.push(handle) + return target.proxy + } + + /** + * + * @param type + * @param handle + * @return {proxy} + */ + function off (type, handle) { + var listeners = hooks[type] || [] + var next = [] + + forEach(listeners, function (fn) { + if (fn !== handle) { + next.push(fn) + } + }) + + listeners[type] = next + + return target.proxy + } + } + + // initialize + sugoio._i = [] + sugoio.init = function (token, config, name) { + // support multiple sugoio instances + var target = sugoio + if (typeof(name) !== 'undefined') { + target = sugoio[name] = [] + } else { + name = lib_name + } + + // Pass in current people object if it exists + target.people = target.people || [] + target.toString = function (no_stub) { + var str = lib_name + if (name !== lib_name) { + str += '.' + name + } + if (!no_stub) { + str += ' (stub)' + } + return str + } + + target.people.toString = function () { + // 1 instead of true for minifying + return target.toString(1) + '.people (stub)' + } + + function _set_and_defer (target, fn) { + var split = fn.split('.') + if (split.length === 2) { + target = target[split[0]] + fn = split[1] + } + target[fn] = function () { + target.push([fn].concat(Array.prototype.slice.call(arguments, 0))) + } + } + + // 删除无用的shallow + // create shallow clone of the public sugoio interface + functions = "time_event track track_pageview register register_once unregister set_config".split(' ') + + forEach(functions, function (fn) { + _set_and_defer(target, fn) + }) + + // register sugoio instance + sugoio._i.push([token, config, name]) + } + + // Snippet version, used to fail on new features w/ old snippet + sugoio.__SV = 1.2 + + script = document.createElement("script") + script.type = "text/javascript" + script.async = true + + if (typeof SUGOIO_CUSTOM_LIB_URL !== 'undefined') { + script.src = SUGOIO_CUSTOM_LIB_URL + } else if (win.location.protocol === 'file:' && SUGOIO_LIB_URL.match(/^\/\//)) { + script.src = 'https:' + SUGOIO_LIB_URL + } else { + script.src = SUGOIO_LIB_URL + } + + first_script = document.getElementsByTagName("script")[0] + first_script.parentNode.insertBefore(script, first_script) + +})(document, window.sugoio || []) diff --git a/sugoio-jslib-snippet.min.js b/sugoio-jslib-snippet.min.js new file mode 100644 index 0000000..86114b5 --- /dev/null +++ b/sugoio-jslib-snippet.min.js @@ -0,0 +1,6 @@ +/*! + * sugo-sdk-javascript JavaScript Lib v1.3.13 + * http://sugo.io + * Date: 2017-11-28 + */ +var SUGOIO_LIB_URL="//localhost:8000/_bc/sugo-sdk-js/libs/sugoio-latest.min.js";!function(e,o){function t(e,o){for(var t=0,n=e.length;t + + + sugoio | Clients + + + + + + + + + + + + + + +
+
+
+
+

Clients

+ +
+
+ +
+
+
+
+
+ 下载 +
+
+
+
+
+
+ Last modification: 2:10 pm - 12.06.2014 +

Clients

+

+ All clients need to be verified before you can send email and + set a project. +

+
+ + + + +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anthony Jackson Tellus Institute gravida@rbisit.comActive
Rooney LindsayProin Limited rooney@proin.comActive
Lionel McmillanEt Industries +432 955 908
Edan RandallInteger Sem Corp. +422 600 213Waiting
Jasper CarsonMone Industries +400 468 921
Reuben PachecoMagna Associates pacheco@manga.comPhoned
Simon CarsonErat Corp. Simon@erta.comActive
Rooney LindsayProin Limited rooney@proin.comWaiting
Lionel McmillanEt Industries +432 955 908
Edan RandallInteger Sem Corp. +422 600 213
Anthony Jackson Tellus Institute gravida@rbisit.comDeleted
Reuben PachecoMagna Associates pacheco@manga.comActive
Edan RandallInteger Sem Corp. +422 600 213Phoned
Jasper CarsonMone Industries +400 468 921Active
Reuben PachecoMagna Associates pacheco@manga.comActive
Simon CarsonErat Corp. Simon@erta.com
Rooney LindsayProin Limited rooney@proin.com
Lionel McmillanEt Industries +432 955 908Active
Edan RandallInteger Sem Corp. +422 600 213Phoned
Anthony Jackson Tellus Institute gravida@rbisit.comWaiting
Reuben PachecoMagna Associates pacheco@manga.com
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tellus InstituteRexton AngolaActive
Velit IndustriesMaglie LuxembourgActive
Art LimitedSooke Philippines
Tempor Arcu Corp. + Eisden Korea, NorthWaiting
Penatibus Consulting + Tribogna Montserrat
Ultrices + IncorporatedBasingstoke TunisiaActive
Et Arcu Inc.Sioux City BurundiActive
Tellus InstituteRexton AngolaActive
Velit IndustriesMaglie Luxembourg
Art LimitedSooke Philippines
Tempor Arcu Corp. + Eisden Korea, NorthWaiting
Penatibus Consulting + Tribogna Montserrat
Ultrices + IncorporatedBasingstoke TunisiaActive
Et Arcu Inc.Sioux City BurundiActive
Tellus InstituteRexton AngolaActive
Velit IndustriesMaglie Luxembourg
Art LimitedSooke Philippines
Tempor Arcu Corp. + Eisden Korea, NorthWaiting
Penatibus Consulting + Tribogna Montserrat
Ultrices + IncorporatedBasingstoke TunisiaActive
Et Arcu Inc.Sioux City BurundiActive
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+

Nicki Smith

+ +
+ +
+
+
+ + About me + + +

+ Lorem ipsum dolor sit amet, consectetur adipisicing + elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +

+ +
+
+
+
+ + Last activity + +
    +
  • + 09:00 pm + Please contact me +
  • +
  • + 10:16 am + Sign a contract +
  • +
  • + 08:22 pm + Open new shop +
  • +
  • + 11:06 pm + Call back to Sylvia +
  • +
  • + 12:00 am + Write a letter to Sandra +
  • +
+ Notes +

+ Lorem ipsum dolor sit amet, consectetur adipisicing + elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +

+
+ Timeline activity +
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+
+

Edan Randall

+ +
+ +
+
+
+ + About me + + +

+ Many desktop publishing packages and web page editors + now use Lorem Ipsum as their default tempor incididunt + model text. +

+ +
+
+
+
+ + Last activity + +
    +
  • + 09:00 pm + Lorem Ipsum available +
  • +
  • + 10:16 am + Latin words, combined +
  • +
  • + 08:22 pm + Open new shop +
  • +
  • + 11:06 pm + The generated Lorem Ipsum +
  • +
  • + 12:00 am + Content here, content here +
  • +
+ Notes +

+ There are many variations of passages of Lorem Ipsum + available, but the majority have suffered alteration in + some form, by injected humour, or randomised words. +

+
+ Timeline activity +
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+
+

Jasper Carson

+ +
+ +
+
+
+ + About me + + +

+ Latin professor at Hampden-Sydney College in Virginia, + looked embarrassing hidden in the middle. +

+ +
+
+
+
+ + Last activity + +
    +
  • + 09:00 pm + Aldus PageMaker including +
  • +
  • + 10:16 am + Finibus Bonorum et Malorum +
  • +
  • + 08:22 pm + Write a letter to Sandra +
  • +
  • + 11:06 pm + Standard chunk of Lorem +
  • +
  • + 12:00 am + Open new shop +
  • +
+ Notes +

+ Lorem Ipsum passage, and going through the cites of the + word in classical literature, discovered the undoubtable + source. +

+
+ Timeline activity +
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+
+

Reuben Pacheco

+ +
+ +
+
+
+ + About me + + +

+ Finibus Bonorum et Malorum" (The Extremes of Good and + Evil) by Cicero,written in 45 BC. This book is a + treatise on. +

+ +
+
+
+
+ + Last activity + +
    +
  • + 09:00 pm + The point of using +
  • +
  • + 10:16 am + Lorem Ipsum is that it has +
  • +
  • + 08:22 pm + Text, and a search for 'lorem ipsum' +
  • +
  • + 11:06 pm + Passages of Lorem Ipsum +
  • +
  • + 12:00 am + If you are going +
  • +
+ Notes +

+ Lorem Ipsum which looks reasonable. The generated Lorem + Ipsum is therefore always free from repetition, injected + humour, or non-characteristic words etc. +

+
+ Timeline activity +
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+

Tellus Institute

+ +

+ Finibus Bonorum et Malorum" (The Extremes of Good and + Evil) by Cicero,written in 45 BC. This book is a treatise + on. +

+
+ Active project completion with: 48% +
+
+
+
+
+
+
+ + Last activity + +
    +
  • + NEW + The point of using +
  • +
  • + WAITING + Lorem Ipsum is that it has +
  • +
  • + BLOCKED + If you are going +
  • +
+ Notes +

+ Lorem Ipsum which looks reasonable. The generated Lorem + Ipsum is therefore always free from repetition, injected + humour, or non-characteristic words etc. +

+
+ Timeline activity +
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+

Penatibus Consulting

+ +

+ There are many variations of passages of Lorem Ipsum + available, but the majority have suffered alteration in + some. +

+
+ Active project completion with: 22% +
+
+
+
+
+
+
+ + Last activity + +
    +
  • + WAITING + Aldus PageMaker +
  • +
  • + NEW + Lorem Ipsum, you need to be sure +
  • +
  • + BLOCKED + The generated Lorem Ipsum +
  • +
+ Notes +

+ Lorem Ipsum which looks reasonable. The generated Lorem + Ipsum is therefore always free from repetition, injected + humour, or non-characteristic words etc. +

+
+ Timeline activity +
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+

Ultrices Incorporated

+ +

+ Many desktop publishing packages and web page editors now + use Lorem Ipsum as their default model text. +

+
+ Active project completion with: 72% +
+
+
+
+
+
+
+ + Last activity + +
    +
  • + BLOCKED + Hidden in the middle of text +
  • +
  • + NEW + Non-characteristic words etc. +
  • +
  • + WAITING + Bonorum et Malorum +
  • +
+ Notes +

+ There are many variations of passages of Lorem Ipsum + available, but the majority have suffered alteration in + some form, by injected humour. +

+
+ Timeline activity +
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+ +
+
+

There are many variations of passages of Lorem + Ipsum available. +

+ 06:10 pm - 11.03.2014 +
+
+
+ +
+

The generated Lorem Ipsum is therefore. +

+ 02:50 pm - 03.10.2014 +
+
+
+
+ +
+
+

Conference on the sales results for the previous + year. +

+ 2:10 pm - 12.06.2014 +
+
+
+
+ +
+
+

Many desktop publishing packages and web page + editors now use Lorem. +

+ 4:20 pm - 10.05.2014 +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + + + diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..069ec7e --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,135 @@ +/** + * @Author sugo.io + * @Date 17-9-15 + */ +const path = require('path') +const webpack = require('webpack') +const pkg = require('./package.json') +const UglifyJSPlugin = require('uglifyjs-webpack-plugin') +const _ = require('lodash') + +console.log('============building version', pkg.version, '============') +const isProduction = process.env.NODE_ENV === 'production' + +const output = { + path: path.resolve(__dirname, './build'), + filename: '[name].js' +} + +// web-sdk编译 +let webSDKCompile = { + entry: { + 'sugo-sdk': './src/loader-globals.js' + }, + output, + module: { + loaders: [ + { + test: /\.vue$/, + loader: 'vue-loader', + exclude: /node_modules/ + }, + { + test: /\.js$/, + loader: 'babel-loader', + exclude: /node_modules/ + }, + { + test: /\.css$/, + loader: 'style-loader!css-loader' + }, + { + test: /\.(eot|svg|ttf|woff|woff2)(\?\S*)?$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 1024 * 20, + name: '[name].[ext]?[hash]' + } + } + ] + }, + { + test: /\.(png|jpe?g|gif|svg)(\?\S*)?$/, + loader: 'file-loader', + options: { + limit: 1024 * 20, + name: '[name].[ext]?[hash]' + } + } + ] + }, + resolve: { + alias: { + 'vue$': 'vue/dist/vue.common.js' + } + }, + devtool: isProduction ? '' : 'source-map', + plugins: [ + new webpack.DefinePlugin({ + 'process.env': { + 'NODE_ENV': JSON.stringify(process.env.NODE_ENV), + 'SDK_VERSION': JSON.stringify(pkg.version) + } + }) + ] +} + +// 微信小程序编译 +let wxMiniSDKCompile = { + entry: { + 'wx-mini': './src/wx-mini.program.js' + }, + output: { + ...output, + libraryTarget: 'commonjs2' + }, + module: { + loaders: [ + { + test: /\.js$/, + loader: 'babel-loader', + exclude: /node_modules/ + } + ] + } +} + +if (isProduction) { + // webSDKCompile.devtool = void 0 + // http://vue-loader.vuejs.org/en/workflow/production.html + webSDKCompile.plugins = (webSDKCompile.plugins || []).concat( + new UglifyJSPlugin({ + uglifyOptions: { + compress: { + warnings: false, + properties: false, + computed_props: false + }, + ie8: true, + keep_fnames: true, + comments: /^\/\*!/ + } + }) + ) + + // wxMiniSDKCompile.devtool = void 0 + wxMiniSDKCompile.plugins = (wxMiniSDKCompile.plugins || []).concat( + new UglifyJSPlugin({ + uglifyOptions: { + compress: { + warnings: false + }, + output: { + comments: false + } + } + }) + ) +} + +module.exports = [ + webSDKCompile, + wxMiniSDKCompile +]