Search This Blog

Implementation of federated learning on Android

前言

时隔一年之久再次更新博文。😅记录近来的项目经历,留作以后参考,也希望能够帮到有需要的人。
本项目的需求如下:搭建实际的联合学习(Federated Learning)场景,主要包括两个组成部分:服务器与客户端。两者的角色分别是:
  • 服务器:集中处理由客户端上传的更新后的机器学习模型之权重,并将汇集后的新模型传输至客户端,更新客户端的模型。
  • 客户端:利用本地存储的数据对机器学习模型进行训练,并将更新后的模型权重上传至服务器。
联合学习的主要优势在于保障客户数据隐私的同时能够进行大规模的机器学习,获得不亚于传统集中式的机器学习模型的表现。联合学习的概念最早于2016年由Google Brian团队推出,截至近日,Google已经正式发布了相关的平台。Google已经将该技术应用于自家产品Gboard之中,利用客户资源(输入数据)增强Gboard输入预测的能力,优化客户的使用体验。联合学习系统中,一般会由服务器向客户端下发一个基础的模型;在此之上,各个客户端再结合本地数据对模型进一步的训练,将训练后的模型权重上传至服务器;服务器根据客户端上传的模型权重,综合得到新的模型权重。如此往复,联合学习系统可以实现在保障用户数据隐私的情况下,精进机器学习模型的性能,提高用户的体验。有关联合学习的详细介绍可以参考以下博文。基于以上的观察,实验室打算做联合学习的相关研究,自然地,需要先把台子搭起来。

TL;DR

平台选择

随着机器学习与深度学习的大热,机器学习平台也井喷式发展。目前最为热门的两大平台分别是Tensorflow于PyTorch。平台热门意味着踩坑的概率小,即便采坑了能够解决的可能性也很高。但是这里有一个问题在于,联合学习中模型训练(Training)的过程发生在终端设备上,例如手机。而实际上,模型训练是一个非常消耗计算资源的过程,这两大平台并未过多关注于终端设备上的训练,相应的文档几乎没有;更多考虑的情形是在服务器或PC上训练好模型后,将存储的模型移植到终端,在终端只“使用”(Inference)模型。在进行一番调研后发现,我们倾向于使用DL4J作为开发平台。DL4J是Deep Learning for Java的缩写,顾名思义,是一个由Java写成的深度学习平台。作为Python当道的机器学习领域,Java的确有些小众,但好在其文档相对完善(虽然没法和“两大”相比),学习成本不高,并且已有现成的项目介绍实现了与我们基本相同的需求。更为关键的一点是,我们选定了Android作为移动端的开发环境,而Java作为Android的原生开发语言与DL4J刚好匹配。

应用选择

联合学习的主要应用场景在于数据敏感的应用,例如用户的输入内容、照片、医疗数据等。在此,我们主要是做一个Proof-of-Can(PoC)的工作,选择相对容易的应用,与此同时还要贴合移动场景,最终选择了:姿态识别(Human Activity Recognition, HAR)应用。该应用可以通过采集用户设备中传感器,如加速度计、陀螺仪的数据,对用户当前的姿态做实时的判定。另一方面,也可以通过用户主动对当前姿态的标记存储新的训练集于终端,并进行本地训练。用于训练基础模型的数据集来自于WISDM实验室,该数据集标定了“Jogging,Walking,Upstairs,Downstairs,Sitting,Standing”共计6种姿态。此数据集包含来自加速度计的数据(x,y,z三个方向),在其最初的论文中,作者通过组合原始数据构造、提取了共计43个维度的特征,再用于模型训练。除此以外,有关HAR的数据集还有Human Activity Recognition Using Smartphones Data Set,该数据集除了加速度计还包括陀螺仪的数据,数据集样本更多。简便起见,我们选择了WISDM的数据集。

数据集预处理

原始的数据集每一条记录如下所示:
33,Jogging,49105962326000,-0.6946377,12.680544,0.50395286;
分别记录了用户ID,姿态类型,时间戳以及三个方向的加速度值。我们根据此博文中给出方法对原始数据进行封装,构成我们需要的数据集。具体而言,以90为窗口大小,将连续的90条记录组合为一条新的记录,将这些记录中出现次数最多的姿态标签定义为组合后的记录标签。此外,以窗口大小的一半(即45)为步长,滑动窗口进而构造下一条数据记录。如此一来,我们可以构造新的监督式机器学习模型的数据集,该数据集的输入为具有270个特征的向量,输出为维度为6的向量,对应6种姿态的概率。至此,就得到了我们需要的数据集。值得注意的是,原始数据集种存在若干无效数据,在实际处理过程中,将无效数据直接跳过。另一方面,由于不同用户可能存在差异,所以各个用户的数据也通过ID的区别相互分离,分别从每个用户的数据中提取构建新的数据集。新的数据集中一条记录形如:
1,5.63,7.86,0.31,2.53,12.98,1.04,...(共计 270 个特征)
其中第一个数字表示姿态的编号,剩余的270个数字来自同一ID下连续有效的90条原始记录。
数据预处理的过程由Matlab实现,具体的代码可以参见项目地址

模型选择

简便起见,我们选择了基本的Neural Network,包含一个Hidden Layer,神经元数量为1000。通过DL4J构建所需的神经网络语法与Keras十分相似,较为直观。示例代码如下:
MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
 .seed(seed)
 .weightInit(WeightInit.XAVIER)
 .updater(new Nesterovs(learningRate, 0.9))
 .list()
 .layer(new DenseLayer.Builder().nIn(numInputs).nOut(numHiddenNodes)
  .activation(Activation.RELU)
  .build())
 .layer(new DenseLayer.Builder().nIn(numHiddenNodes).nOut(numHiddenNodes)
  .activation(Activation.RELU)
  .build())
 .layer(new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
  .activation(Activation.SOFTMAX)
  .nIn(numHiddenNodes).nOut(numOutputs).build())
 .build();
以上代码构建了上述的神经网络模型。在DL4J的教程中对模型构建有做入门的介绍。

通信方式

作为实际的联合学习系统,少不了客户端与服务器间的通信。客户端与服务器之间需要交换更新的模型权重,具体而言就是文件的传输。这一点可以通过RESTful框架实现。此处介绍一个更为偷懒的方式,借助Dropbox实现。由于,项目的出发点更多在于PoC,通信方式的实现暂且略过。在客户端安装Dropbox+DropSync两个App,登陆同一账号;在服务器端安装Dropbox,登陆同一账号即可实现简易版的通信环境。Dropbox在服务器端会自动同步Dropbox同步文件夹中的内容,如此可以下载由客户端更新后的模型。而客户端也可以通过DropSync链接到Dropbox并设置自动(双向)同步的文件夹,如此,将客户端更新后的模型存储于该文件夹中即可自动上传至Dropbox服务器。为了解决文件冲突的问题,各个客户端可以在存储更新后的模型时为文件名添加设备ID作为区分。(当然,这样处理的弊端是,每个设备都会同步其他设备本地训练的模型,当设备数量增加时,此项开销是相当可观且完全没有必要的;仍然,由于是PoC,暂且忽略。)

模型存储与加载

上述提到,客户端与服务器之间更新模型的必要步骤是存储与加载模型。在DL4J中,这一点的实现也给了明确的说明,示例代码如下:
// 存储模型
File locationToSave = new File("Trained_HAR_NN.zip");
ModelSerializer.writeModel(model, locationToSave, saveUpdate);

// 加载模型
File locationToLoad = new File("Trained_HAR_NN.zip");
MultiLayerNetwork model = ModelSerializer.restoreMultiLayerNetwork(locationToLoad, false);
其中writeModelrestoreMultiLayerNetwork分别用于存储、加载模型。需要注意的是,这两个函数都有一个额外的boolean类型参数,设置是否需要保存、加载模型的updater。机器学习模型在学习过程中一般会动态调整一些控制参数,例如:learning rate,该功能通过updater实现。在DL4J中,将updater独立于优化方法,提高了灵活性。如果该参数设为false,那么将不会存储updater的状态。相应的在Android中模型的存储于加载过程也是完全一致的,需要注意的是Android中需要在AndroidManifest.xml中声明读取权限,如下所示:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
此外,Android开发者文档中也给出了申请读写权限的代码块。

客户端(App)

客户端的功能需求包括:
  • 实时采集、绘制传感器(加速度计)数据;
  • 用户主动式标记姿态;
  • 本地模型训练;
  • 更新机器学习模型;
  • 用户姿态检测。
为了实现以上功能,App界面设计包括:Chart控件(第三方)、Button、Progress Bar。其中Chart控件采用MPAndroidChart,是一个成熟的第三方绘图控件。为标记(labeling)、训练(training)、检测(inference)、更新模型(updating)等功能分别设置Button控件,只需实现onClick方法,在其中对应调用相应的功能代码即可。而Progress Bar的存在主要是因为,模型训练、更新以及标记等功能相对比较耗时,通过Progress Bar的显示可以提示用户当前正在执行操作。

传感器数据采集与绘制

Android开发者文档中详细介绍了传感器数据采集的方法,在此不做赘述。有了数据以后,我们还需要将其展示出来,MPAndroidChart是一个简便的方式。油管上有一个视频展示了如何实现这一点。其中关键的步骤包括:
data.notifyDataChanged();
mChart.notifyDataSetChanged();
MPChart中的几个关键概念包括:Chart/DataSet/Data,可以这样类比:Chart是画板,DataSet是画板上画的所有的图的集合,而Data是这些图中的一个,例如一条线。因此得依次由data通知其值的变化、通知DataSet的变化才能获得数据更新事件的通知,从而更新画板。(这一点在视频中写漏了,底下的评论指出了错误。😅)

AsyncTask类

上述提到模型训练等过程相对比较耗时,那么一个比较好的办法是借助AsyncTask类将比较耗时的操作放置于后台完成。有关AsyncTask的用法,这篇博文介绍得非常清晰详尽,值得一看。该类包括如下几个方法:
一个异步任务的执行一般包括以下几个步骤:
1.execute(Params... params),执行一个异步任务,需要我们在代码中调用此方法,触发异步任务的执行。
2.onPreExecute(),在execute(Params... params)被调用后立即执行,一般用来在执行后台任务前对UI做一些标记。
3.doInBackground(Params... params),在onPreExecute()完成后立即执行,用于执行较为费时的操作,此方法将接收输入参数和返回计算结果。在执行过程中可以调用publishProgress(Progress... values)来更新进度信息。
4.onProgressUpdate(Progress... values),在调用publishProgress(Progress... values)时,此方法被执行,直接将进度信息更新到UI组件上。
5.onPostExecute(Result result),当后台操作结束时,此方法将会被调用,计算结果将做为参数传递到此方法中,直接将结果显示到UI组件上。

借助AsyncTask类,我们就可以将模型训练、模型更新、标记等功能封装于异步过程之中。

服务器

在联合学习系统中,服务器负责收集来自客户端更新的模型权重,再对这些权重进行聚合,一般采用平均的方式。实现这一功能,在DL4J中也比较直接。ND4J是之于DL4J就如同Tensor之于Tensorflow,是DL4J中多维矩阵的实现。DL4J的模型参数通过一个Map存储,Map的键-值对为<层名称,权重>,可以通过model.paramTable()方法获得该Map。需要注意的是,对于神经网络,DL4J中每一层分别包含权重以及Bias,存储于paramTable中时,默认的名称分别是x_W以及x_b,其中x表示层序号,从0开始。如下给出服务器端平均权重的函数实现:
public static void AverageWeights(List<File> files, File originModel, int layer, double alpha) {
    /*
        files indicates locations that mobile device uploaded model
        originModel is the model maintained by the server
        layerName is the layer to be averaged
        alpha is a coefficient indicates the weight of original model for the updated model
        currently, we just do transfer learning on the devices and we assume that it happens only at
        the last layer (i.e., the output layer) and keep other layers friezed. Therefore, we just need
        to average weights over the last layer.
     */
    // load original model
    MultiLayerNetwork model = null;
    try {
        model = ModelSerializer.restoreMultiLayerNetwork(originModel, false);
    } catch (IOException e) {
        e.printStackTrace();
    }
    Map<String, INDArray> paramTable = model.paramTable();
    INDArray weight = paramTable.get(String.format("%d_W", layer));
    INDArray bias = paramTable.get(String.format("%d_b", layer));
    INDArray avgWeights = weight.mul(alpha);
    INDArray avgBias = bias.mul(alpha);

    // average weights over mobile devices' models
    int len = files.size();
    for (int i = 0; i < len; i++) {
        try {
            model = ModelSerializer.restoreMultiLayerNetwork(files.get(i), false);
        } catch (IOException e) {
            e.printStackTrace();
        }
        paramTable = model.paramTable();
        weight = paramTable.get(String.format("%d_W", layer));
        avgWeights = avgWeights.add(weight.mul(1.0-alpha).div(len));
        bias = paramTable.get(String.format("%d_b", layer));
        avgBias = avgBias.add(bias.mul(1.0-alpha).div(len));
    }
    model.setParam(String.format("%d_W", layer), avgWeights);
    model.setParam(String.format("%d_b", layer), avgBias);
    try {
        ModelSerializer.writeModel(model, "res/model/trained_har_nn_updated.zip", false);
    } catch (IOException e){
        e.printStackTrace();
    }
}

Transfer Learning

最后,考虑到模型训练过程相当消耗资源,如果在移动端训练完整、复杂的模型基本是不现实的。而借助Transfer Learning技术能够有效解决这一问题。简而言之,Transfer Learning可以固定模型中的若干层,而仅仅训练剩余的层。如此,可以在保有模型深度的同时,减小训练的规模(参数减少),提高训练的速度,降低能耗,非常适合移动端场景。相应的,DL4J中也提供了Transfer Learning的API与教程。示例如下:
transferred_model = new TransferLearning.Builder(model)
        .fineTuneConfiguration(fineTuneConf)
        .setFeatureExtractor(1)
        .build()
其中关键的是setFeatureExtractor(x)这一方法,将模型位于第“x+1”层以前(含)的层全部设为Frozen状态。如此,新的模型在获得原模型前“x+1”层的权重的同时,将这些权重固定下来,新的模型将只会训练“x+2”及以后的层。需要注意的是,该方法只计算未处于Frozen状态的层数,一旦模型的中的某层被设为Frozen状态,那么该方法计数时将跳过该层。举例而言,对于一个三层的模型,首次调用setFeatureExtractor(1)得到的模型中前两层均被设置为Frozen,如果再次调用setFeatureExtractor(1)将得到错误信息,因为此时该模型已经只剩一层未处于Frozen状态,而该方法不能将模型所有的层均设为Frozen。

结语

综上,整理了联合学习之安卓实现的项目点滴。由于对Java与Android了解不多,代码组织上有许多可以精进之处,以后有机会再做改进。此外,目前Google已经将Kotlin设为Android开发的首选语言,上述提到的主要参考项目也是用该语言开发,将来可以考虑将项目用Kotlin重构。

中国省市自治区行政区域数据可视化

动机

近期的论文中需要对省市自治区数据进行可视化,查询资料后获取如下的解决方案:
  • Excel Power Map
  • Matlab geoshow/mapshow
  • Mapshaper

需求说明

本次需要绘制的数据图为区域图,即对各省市自治区按照数值大小用不同颜色反映出数据的差异,将相应区域整体着色。

Demo

中国各省市自治区电动汽车公共充电桩数量分布(截至2017年12月)
如图所示,通过区域绘图的方式绘制了充电桩的分布情况(Matlab实现)。其他与省市相关的数据可视化均可以通过这种方式展示。

解决方案

下面分别对以上方案进行简单介绍与对比。

Excel Power Map

自Office 2016以来,Excel 集成了Power Map功能(使用时需要联网);Office 2013需要单独下载安装Power Map工具;再之前的版本不支持该功能。Power Map的优势在于上手方便,无需编程。在Excel表格中选中相应的数据即可添加至Power Map加载项中即可实现相应的需求。需要注意的是:该功能中务必将省市自治区的名称完整写出,例如:北京市、广东省、广西壮族自治区,不能写为北京、广东、广西;否则在选择数据类型时无法准确识别。此外,Power Map可以选择不同的数据表示方式,包括:柱状图、热力图、区域图;根据需求,我们需要采用区域图。
优点
  • 上手快,无需编程
缺点
  • 颜色只能选择单一色调,颜色深浅是按照原有数据自动配置色彩深浅。(这一点可以通过对数据预处理解决,对原始数据先进行分级)
  • 地图为世界地图,无法单独抠出中国地图
  • 图例形式单一

Matlab geoshow/mapshow

Matlab中提供了地图绘制的函数,geoshowmapshow
可以方便地从地图文件(Shapefile, .shp)中提取经纬度信息,绘制成地图。具体的操作如下代码所示:
s = shaperead('省界_region.shp'); % load shapefile
axesm('mercator') % set projection type as mercator projection
set(gca, 'Color', 'none') % set background transparent
set(gca,'XColor','none'); set(gca,'YColor','none'); % set boundary transparent
for k = 1 : length(s)
geoshow(s(k).Y, s(k).X, 'DisplayType', 'polygon');
hold on
end
首先读取地图文件,存储为地图结构体,其中包括34个省市自治区的边界信息;然后设置坐标轴的投影类型为墨卡托投影(这一点在后面会补充提到);设置坐标轴以及背景为空为了仅显示地图而不显示背景与坐标轴;通过循环利用geoshow函数绘制各个省市自治区。geoshow函数的用法如下:
handle = geoshow(lan, lon, 'DisplayType', 'polygon', 'FaceColor', [r, g, b]);
与普通的plot函数用法别无二致。通过设置facecolor即可完成对区域着不同颜色的目标,而此处着色的方式就更为自由;handle为图形的句柄,可以为后续设置图例使用。总结Matlab geoshow/mapshow方式的优缺点如下:
优点
  • 自由度高,能达成所有目标
  • 了解matlab而言,上手容易
缺点
  • 需要安装matlab
  • 有一定门槛

Mapshaper

mapshaper是一个开源地图编辑软件,由纽约时报记者Matthew Bloch开发。该工具基于Node.js开发,可以在windows,Mac OS,Linux系统下安装,或者由其提供的web端运行,该工具使用方便,功能丰富。以web端为例,用户只需要将地图文件(以shapefile为例,是.zip压缩包)拖拽至web端,网页将自动加载地图文件;网页端提供控制台(console),用户可以在控制台中输入相关的命令即可完成对地图的编辑,从而实现数据可视化的目标。(该web端提供解析地图的本地脚本,并不需要将数据上传至服务器,因此安全性与操作性都可以保障)下面的命令行脚本将提供相关的数据可视化功能示例:
mapshaper 省界_region.shp -join evse_distribution.csv keys=ID,ID fields=evse_number -o joined.shp
以上代码完成的功能是将外部数据添加到地图文件中,便于后续的数据展示。
mapshaper joined.shp -svg-style fill="#CCE5FF" where="evse_numbe <= 1000" -svg-style fill="#99CCFF" where="evse_numbe > 1000 && evse_numbe <= 3000" -svg-style fill="#66B2FF" where="evse_numbe > 3000 && evse_numbe <= 5000" -svg-style fill="#3399FF" where="evse_numbe > 5000 && evse_numbe <= 10000" -svg-style fill="#0080FF" where="evse_numbe > 10000 && evse_numbe <= 20000" -svg-style fill="#0066CC" where="evse_numbe > 20000 && evse_numbe <= 25000" -svg-style fill="#004C99" where="evse_numbe > 25000" -svg-style stroke-width=1.5 -svg-style stroke="black" -proj merc -o evse_numbers.svg
以上代码完成对载入数据的可视化展示并生成svg格式图片。

其中的主要语句包括:-join, -svg-style,-proj以及-o

优点
  • 自由度高,无需安装任何软件(web接口)
缺点
  • 有一定编程门槛
  • 图例不便于程序化实现
注意
  • dbf文件写入时,对于非ascii编码文件,字段长度截断为10个字符;因此在以上的代码中“evse_number”在后续调用时变成了“evse_numbe”;这一点在此前的版本没有截断提示,近期更新作者已完善提醒

其他

地图文件格式

Shapefile格式与说明如下:
xxx.zip
   |--- xxx.dbf:    属性数据格式,以dBase IV的数据表格式存储每个几何形状的属性数据。
   |--- xxx.prj:     投帧式,用于保存地理坐标系统与投影信息。
   |--- xxx.shp:    图形格式,用于保存元素的几何实体。
   |--- xxx.shx:    图形索引格式。

投影算法

地图的投影算法决定了地图数据展示的效果;国内常用的投影算法为墨卡托投影。详细的投影算法介绍可以参见文章

中国省界地图下载

下载链接:谷歌云盘百度云盘
解压密码:malagis.com

CVX 添加自定义函数

最近考虑的一个优化问题中出现了如下的函数:
$$f(x, y)=\begin{cases}
y 2^{\frac{x}{y}}, & x > 0, y > 0\\
0, & y = 0, x \geq 0\\
+\infty, & \text{otherwise}
\end{cases}$$
首先,这个函数实际上是$2^x$的perspective function(参见Boyd 教材, P103, 3.2.6),perspective function具有保凸性质,而$2^x$为凸函数,所以$y 2^{x/y}$是凸函数。

虽然这个函数是凸函数,但是其形式并不能直接通过cvx表达式表示,因为$\frac{x}{y}$是{affine}./{affine}违背了cvx的DCP ruleset。实际上在给定的定义域内$\frac{x}{y}$是convex的,但是$y \cdot 2^{x/y}$ 为{affine}.*{convex}同样违背了DCP ruleset。总而言之,这个函数不能直接应用于cvx的语境中。

而cvx提供了增加自定义凸(凹)函数的方法,在用户手册中以 Adding new functions to the atom library 章节给出,其中提供了两种方式,一种比较直观,通过符合DCP ruleset的函数或操作符的组合所形成的函数,实际上这种方式是一种简单的封装;但在这里并不适合本函数,原因已在之前给出。另外一种方式,是把原问题转化为另一个凸(凹)问题,转换后的问题符合DCP ruleset或借助cvx提供的若干函数,诸如:rel_entr, 我理解这种方式写成的函数在新的cvx环境下就构成了cvx的嵌套。本函数可以通过第二种方式表达,如下所示:
function cvx_optval = myFoo( x, y )
cvx_begin
    variables z;
    minimize( z );
    subject to
        {x * log(2), y, z} == exponential;
cvx_end
注意到,myFoo有两个输入参数xy,这两个参数实际上都是cvx expressions,而非numerical inputs;另一方面,{x * log(2), y, z} == exponential表示的是{x * log(2), y, z}满足关系$y e^{\frac{x \ln (2)}{y}} \leq z, y>0$。因此,通过minimize z同时将结果传递给cvx_optval就可以得到这个函数的结果了。具体用法如下:
cvx_begin quiet
    variables x y
    minimize ( myFoo(x, y) )
    x >= 2; y<= 3;
cvx_end
本例中 myFoo 是作为目标函数给出,同样的,这个函数也可以出现在约束条件中,如下:
cvx_begin quiet
    variables x y
    minimize ( x + y )
    x >= 1
    myFoo(x, y) <= 10;
cvx_end
综上,我们就通过用户手册中给出的第二种方式添加了自定义的凸函数,可以用于cvx的编程了。
参考
[1]. Exponential perspective function on CVX
[2]. How to add self-defined convex function to atom library?

自定义IEEE Xplore文献列表

IEEE Xplore 数据库提供了文献列表下载服务,可以方便地导出所查询文献的相关信息,存储与csv文件中。相关信息包括:文献名称、作者、发表年份、发表刊物等共计31个标签。在上一篇博文(批量下载IEEE Xplore数据库论文)中给出了根据csv文件下载文献的方法,在此做进一步补充:自定义下载的文献列表信息,例如只选择关注其中的若干标签,如:文献名称、作者、发表年份、发表刊物、引用次数这五个标签。此外,为每个条目添加文献超链接,关联到本地已下载的文献,从而便于索引和查看。

可以通过Matlab快速实现以上两个目标,涉及的主要函数如下:
  • xlsread / xlswrite
  • actxserver
其中,xlsread xlswrite 分别读取和写入excel(csv)文件;而 axtxserver 可以创建windows的COM组件,从而操作该对象,例如:exl = axtxserver('excel.application') 可以创建一个Excel对象。以下给出通过Matlab为Excel单元格添加超链接的实现示例:
exl = actxserver('excel.application');
exlWkbk = exl.Workbooks;
exlFile = exlWkbk.Open([pwd '/' filename]);
exlSheet1 = exlFile.Sheets.Item('Sheet1');

rngObj = exlSheet1.get('Cells', row, col);
exlSheet1.Hyperlinks.Add(rngObj, 'somelink');

exlFile.Save()
exlFile.Close()
exl.Quit
exl.delete

在创建Excel对象后,可以调用Excel VBA中的方法对Excel单元格进行访问,而其中添加超链接的方式即: exlSheet1.Hyperlinks.Add(rngObj, 'somelink'); 值得注意的是以R1C1方式访问Excel单元格的方式为:rngObj = exlSheet1.get('Cells', row, col); 完整代码如下所示:
function SelectInterestTags(export, filename, savepath)
%% Select interested tags from IEEE Xplore export file
%   and generate an excel file which contains hyperlink for each entry to
%   locate the downloaded file
%
%   export: csv file downloaded from IEEE Xplore
% filename: saved excel filename
% savepath: path for downloaded pdf files
%
clc

%% initial
switch(nargin)
    case 2
        savepath = pwd;
    case 3
        % do nothing
    otherwise
        error('Wrong for number of inputs.')
end

%% load csv file
[raw_numerical, raw_text, RAW] = xlsread(export);
NameList = raw_text(3:end, 1);
YearList = raw_numerical(1:end, 2);

pat = '[\\/:*?"<>|]';
NameList = regexprep(NameList, pat, ' ');

%% disp all tags and choose interest tags
Tags = raw_text(2, :);
for k = 1 : length(Tags)
    fprintf('\t%d\t%s\n', k, Tags{k});
end

prompt = 'Please Select Your Interest Tags (-1 for all, 0 for default): ';
interestTags = input(prompt);

%% parameter check
if interestTags == -1
    interestTags = 1:length(Tags);
else
    if interestTags == 0 % default selection
        interestTags = [1, 22, 4, 6, 11, 17, 24, 2];
    end
end

disp('The following tags are selected: ')
disp(Tags(interestTags)')

%% write xls file
InterestArray = RAW([2:end], interestTags);
xlswrite(filename, InterestArray);

%% add hyperlink for each paper

exl = actxserver('excel.application');
exlWkbk = exl.Workbooks;
exlFile = exlWkbk.Open([pwd '/' filename]);
exlSheet1 = exlFile.Sheets.Item('Sheet1');

for k = 1 : length(InterestArray) - 1
    pdfFile = [savepath '/' num2str(YearList(k)) ' ' NameList{k} '.pdf'];
    if exist(pdfFile, 'file') == 2
        rngObj = exlSheet1.get('Cells', k + 1, 1);
        exlSheet1.Hyperlinks.Add(rngObj, pdfFile);
    end
end
disp([filename ' generated!'])

%% save file and close activex excel com
exlFile.Save()
exlFile.Close()
exl.Quit
exl.delete

参考


批量下载IEEE Xplore数据库论文

在文献调研初期一般需要大量下载相关论文,IEEE Xplore提供了简单的批量下载功能,一次最多10篇,不算方便;此外,短时间内连续批量下载会遭到服务器拒绝,导致一段时间内无法下载文献。因此,考虑写个小脚本下载所需查阅的文献。

好在Xplore提供了导出文献列表的功能,如下图所示:
勾选所需的文献后,点击导出即会自动下载一个csv文件,保存有文献的相关信息,如:标题、年份、作者、文献链接等,如下图所示:

而文献链接为URL地址,打开后为PDF的浏览界面,PDF文件封装于一个<iframe></iframe>标签中。
基于以上的信息,可以设计如下的脚本逻辑:
采用Matlab实现,主要用到两个网络函数:webread 和 websave (于R2014b后引入,低于此版本的Matlab可以采用urlread 和 urlwrite 替代)
  • webread(url) 输入参数为url地址,访问该地址并以字符串的形式返回html代码;
  • websave(filename, url) 将url地址内容保存到本地并命名为filename。
以上逻辑描述如下:读取csv函数,提取出文献的链接以及文献相关信息;其中链接作为webread输入参数,调用后获得html内容,字符串提取出pdf的下载地址,然后通过websave下载pdf文件;结合csv读取的文献信息为文献命名。

注意事项

  • 以上讨论基于具备IEEE Xplore访问权限的前提,一般校园网均具备;
  • 在以文献标题作为文件名保存时需要注意通配符的问题,例如:“\/:*?"<>|”这些字符是无法存在于文件名中的,所以需要考虑将这些字符替换,比如替换为空格。可以通过正则表达式实现,Matlab中regexprep可用;
  • 下载时最好设置相邻下载间的等待时间从而模拟人工操作避免被封IP,例如可以用: pause(30 * rand() + 30) 模拟随机的等待时间。

代码

function DownloadPDFfromXplore(export, skip)
%% download pdf from IEEE EXplore export file
% if termites at any exception, we can restart and skip those downloaded 
if nargin == 1
    skip = 0;
end

[raw_numerical, raw_text, ~] = xlsread(export);
UrlList = raw_text(skip+3:end, 16);
NameList = raw_text(skip+3:end, 1);
YearList = raw_numerical(skip+1:end, 2);

pat = '[\\/:*?"<>|]';
NameList = regexprep(NameList, pat, ' ');

for k = 1 : length(NameList)
   html = webread(UrlList{k});
   first = strfind(html, '<iframe src="h');
   last = strfind(html, '" frameborder=0>');
   url = html(first+13:last-1);
   disp(url)
   filename = [num2str(YearList(k)) ' ' NameList{k} '.pdf'];
   disp(filename)
   websave(filename, url);
   waitTime = 30 * rand() + 30;
   pause(waitTime);
end

Chrome加载PDF


问题描述:

Chrome在网上查阅PDF文档时经常出现如图中的情况,无法加载PDF文档(本机装有Acrobat),但是使用IE访问相同的链接可以成功加载。原因在于Adobe Acrobat/Reader使用NPAPI技术在Chrome中加载PDF文档,而Google在2015年宣布废弃对该技术的支持。

"Adobe Acrobat and Acrobat Reader run as a plug-in to display PDF files in a web browser. For Google Chrome and Mozilla Firefox, the plug-in is based on the Netscape Plug-In API (NPAPI) technology. "

"Google announced that in April 2015 NPAPI plug-in support is disabled by default in the Google Chrome web browser with an override capability for advanced users. In September 2015, NPAPI support in the Google Chrome web browser was removed entirely."


解决方案:

考虑到IE可以加载PDF文档,因此可以在Chrome中打开IE标签页,而正好Chrome中提供了这样的插件:IE Tab,安装后再安装一次其提供的exe程序即可在在Chrome中以IE内核加载网页。当遇到PDF无法加载的情况时点击“IE Tab”图标即可切换至IE内核从而正确加载PDF文档。

参考链接:

[1]. Change in support for Acrobat and Reader plug-ins in modern web browsers

Solution for Calculator: the Game

动机

最近玩到了一款手机游戏,名字叫做 Calculator: the Game,游戏玩法很简单,通过给定的操作符在给定的步数以内将原始的数字变换到目标数。起初的一些关卡较为容易,但是后续的关卡短时间内无法想到求解思路,所以产生了“暴力求解”的想法。为了快速实现需求,采用Matlab实现。

Demo


思路

基本思路是遍历求解空间,此时需要解决两个主要问题:
  • 如何不重复的遍历求解空间?
  • 如何确定一组求解策略的计算结果?
针对这两个问题,分别有如下的考虑:
  • 由于步数($m$)是已知的,每一步可以选择的操作符个数($n$)也是固定的,那么全体解空间的总数也是确定的,为:$n^m$;另一方面,对于$n$进制的$m$位数总共的可能性也同样为$n^m$,因此可以将$0 \sim n^m-1$的数一一映射到$n$进制的$m$位数。Matlab中提供了10位数到任意进制的转换函数dec2base
  • 另一方面,需要在已知策略的情况下,计算出最终结果并且和目标进行比对;为了实现这一功能,需要明确根据输入的操作符提取并实现相应的操作,这可以通过匿名函数实现。
综上,设计了两个函数,分别命名为parsersolution,其中parser负责解析输入的操作符,并且将所有的操作符保存到cell中;而solution负责遍历求解空间,找出可行解。

这款游戏提供的操作符列表如下:
% operator list:
%       '-3': minues 3
%       '+5': plus 5
%       'x2': times 2
%       '/3': divides 3
%         10: add '10' at the end of current number
%      '+/-': change sign
%       '<<': backspace
%  'reverse': reverse number, keep negetive sign ('-') if it exists
%   'mirror': 32 -> 2332
%    '5=>13': replace
%   'shift>': 231 -> 123
%   '<shift': -150 -> -501
%     '[+]2': increase each number of operators 2 (e.g. +3 -> +5)
%      'sum': 1023 -> (1+0+2+3) -> 6
%      'x^3': power
%    'inv10': 1250 -> (10-1, 10-8, 10-5, 0) -> 9850
%    'store': store current result as a number operator (ignore negetive)
%     [4, 2]: portal indicator, 4th digital will added to the 2nd digital
%             1123 -> (123 + 10) -> 133

其他

代码是一边玩一边扩展的,以为所有的操作符都是改变当前的值,直到遇到了store这个元素,其操作规则是存储当前的值,并且变为一个数字按键,但是store本身并不消耗游戏步数;这一设定着实让我费了一番脑筋。考虑到store本身不会对当前值产生影响且不消耗步数,因此只需要考虑每一步操作之前是否需要执行store即可。而对于每一步之前都有“执行”或“不执行”两种操作,因此对于每一组策略,共可以衍生出$2^m$种可能。(注:其中包含重复或无效情况,后续分析)可以类比遍历求解空间的方法,为store添加一个m位的mask从而标记每一步之前是否需要store

代码

项目代码参见:CalculatorSolution

三点定位

问题来源

在火灾报警电话时,基站一般难以确定与呼叫手机的距离,但是可以确定呼叫手机到两个临近基站的距离差值(原理待考证)。问题在于:能否根据呼叫手机到三个临近基站的两两距离差值确定呼叫手机的位置。

简化描述为:根据目标点到三个锚点间两两的差值确定空间中目标点的位置(轨迹)

数学描述

设目标点为$P$,三个锚点分别为$A,B,C$,目标点到三个锚点的距离差满足如下关系:
$$\left\{\begin{aligned}  PA-PB =& d_1\\ PB - PC=& d_2 \\ PC - PA=& -(d_1+d_2)  \end{aligned}\right.$$
注意:(1) $d_1,d_2$均可以是负数;(2) 以上三个方程实际上等价为两个方程,因为任意一个方程均可以由另外两个导出。
设$P$坐标为$(x,y,z)$,$A,B,C$坐标分别为$(a_1,b_1,c_1)$,$(a_2,b_2,c_2)$,$(a_3,b_3,c_3)$,则以上问题转化为:
$$
\left\{
\begin{aligned}
    & \sqrt{(x-a_1)^2+(y-b_1)^2+(z-c_1)^2} - \sqrt{(x-a_2)^2+(y-b_2)^2+(z-c_2)^2} = d_1 \\
    & \sqrt{(x-a_2)^2+(y-b_2)^2+(z-c_2)^2} - \sqrt{(x-a_3)^2+(y-b_3)^2+(z-c_3)^2} = d_2
\end{aligned}
\right.
$$
注意:以上方程包含三个未知数,而只有两个方程,因此该方程并不只有唯一解。

需求分析

  • 可视化描述目标点到锚点距离差值一定的轨迹方程
  • 计算出目标点的轨迹(数值解或解析解)

解决思路一

Mathematica提供了Solve函数可以用于求解以上方程,但计算结果极为冗长,几乎无法拷贝至其他软件中实现。并且在Mathematica中改变参数进行计算时计算效率也很低,同时存在“除零”的情况。该方法可以用于Demo,但几乎不具备实用性。Demo效果图如图所示。

如图所示,三种颜色的曲面表示分别表示到两锚点距离差一定的轨迹,其中蓝色曲线即为三个曲面的交线。(橙色曲线为对称的交线)

解决思路二

1. 设三锚点为$A, B, C$, 以$A, B, C$形成的平面为$XOY$平面,且以$\overrightarrow{AB}$为$x$轴正方向,$AB$的中点为原点,中垂线为$y$轴正方向;
2. 根据锚点间距离,确定参数$c$(两锚点间距离为$2c$);
3. 根据动点到两锚点间距离差, 确定参数$a$(距离差为$2a$);
4. 相应地确定参数$b = \sqrt{c^2 - a^2}$;
5. 确定两锚点方向相对于$x$轴正方向的旋转角度与平移量;
6. 根据$2 \sim 4$确定相应双叶双曲面的标准方程,然后根据$5$中确定的旋转角度与平移量确定旋转平移后的双叶双曲面方程;
7. 固定$z_0$,即选定平面$Z=z_0$,代入双曲面方程,确定双曲面与该平面的交线(为双曲线)方程;
8. 求两双曲线方程的交点坐标(将$x(y)$代入,转化为关于$y$的一元四次方程[3, 4]);
9. 利用一元四次方程的求根公式计算出求出$y$的解,带回原方程验证,确定最终解即为两双曲面在平面$Z=z_0$上的交点坐标;
10. 遍历$z_0$, 重复步骤$7 \sim 9$。

思路二中的关键步骤

坐标转换顺序

按照以上解决思路中给出的顺序,其中涉及到的坐标转换顺序如图所示。
坐标转换顺序

平面旋转变换

由前面的分析可以知道,三锚点位于$XOY$平面, 那么相应的形成的双叶双曲面的分割面均垂直于$XOY$平面。因此旋转变换仅限于平面$XOY$中,对$z$方向不涉及旋转变换。下面介绍平面旋转变换。
平面直角坐标系中左边平移与旋转变换
如图所示,给出了原始坐标系$XOY$到旋转平移后的坐标系$X'O'Y'$之间坐标的相互转换关系。
注意:从$XOY$到$X'O'Y'$需要先进行平移变换,再进行旋转变换,旋转的角度为顺时针$\alpha$,因此在图中标识为$-\alpha$;反之,从$X'O'Y'$到$XOY$坐标系需要先进行旋转变换,旋转角为逆时针$\alpha$,然后进行平移转换。

子问题

平面(空间)中到两定点距离差固定的动点轨迹为双曲线(双曲面)

证明:假设两定点间的举例为$2c$,而动点到两定点的举例差为$2a$(根据三角形两边之差小于第三边,可知:当$2a>2c$时, 是不存在的;而当$2a=2c$时,动点只可能位于两定点中的任意一个;以下的讨论均假定$2c>2a$)
为了便于讨论,假设两个定点(焦点)的坐标分别位于$(-c, 0)$和$(c, 0)$。设动点坐标为$(x, y)$, 则已知: $$\left| \sqrt{(x-c)^2 + y^2} - \sqrt{(x+c)^2 + y^2}\right| = 2a$$确定$(x, y)$的轨迹方程。
因为$\sqrt{(x-c)^2 + y^2} - \sqrt{(x+c)^2 + y^2} = \pm 2a$,移项可得
$$\sqrt{(x-c)^2 + y^2} = \pm 2a + \sqrt{(x+c)^2 + y^2}$$
两边平方可得
$$ (x-c)^2 + y^2 = 4a^2 + (x+c)^2 + y^2 \pm 4a \sqrt{(x+c)^2 + y^2}$$
化简可得
$$ -4cx - 4a^2= \pm 4a \sqrt{(x+c)^2 + y^2}$$
$$ -cx - a^2= \pm a \sqrt{(x+c)^2 + y^2}$$
两边平方
$$ (cx + a^2)^2= a^2 \left[(x+c)^2 + y^2\right]$$
化简可得
$$x^2(c^2 - a^2) - a^2 y^2 = a^2 (c^2 - a^2)$$
另$b^2 = c^2 - a^2$,可得
$$x^2 b^2 - a^2 y^2 = a^2 b^2$$
两边同时除以$a^2 b^2$,即可得到双曲线的标准方程:
$$\frac{x^2}{a^2} - \frac{y^2}{b^2} = 1$$
将以上的问题推广到空间中是类似的,假设焦点坐标位于$(-c, 0, 0)$与$(c, 0, 0)$,动点坐标为$(x, y, z)$,则动点满足如下关系:
$$\left| \sqrt{(x-c)^2 + y^2 + z^2} - \sqrt{(x+c)^2 + y^2 + z^2}\right| = 2a$$
经过与上述类似的变换(将其中的$y^2$替换为$y^2+z^2$即可)可以得到如下方程:
$$\frac{x^2}{a^2} - \frac{y^2+z^2}{b^2} = 1$$
该方程为双叶双曲线的标准方程(焦点位于$x$轴),以下给出一个示例。
双叶双曲面:$x^2-y^2-z^2=1$

平面与双叶双曲面所形成的交线为双曲线?

如图,给出一个双叶双曲面的切割demo。
Demo:双叶双曲面“切割”
图中分别给出了一个双叶双曲面和三个平面,其中:红色和绿色的平面是双叶双曲面的“渐进平面”即在$XOY$平面上的投影对应为该双叶双曲线在$XOY$平面投影(双曲线)的渐近线。而蓝色平面为切割平面,为了便于观察,绘制蓝色平面与双曲面的交线如下图所示。
双叶双曲面切割平面及其交线
从图中可以看出,蓝色交线为双曲线,下面给出证明以及给出该交线的方程。
首先这一结论是有前提条件的,考虑的“切割”平面是垂直于$XOY$平面的(对于焦点坐标在$X$轴上时,即标准方程为$\frac{x^2}{a^2} - \frac{y^2+z^2}{b^2} = 1$)
证明:切割平面在本例中垂直于$XOY$平面,平面方程可以表述为:$y = kx+m$其中“斜率$k$”的范围在上述“渐进平面”对应的“渐近线”的斜率范围内,即有:$|k| < \frac{b}{a}$
将平面方程$y=kx+m$代入双曲面标准方程$\frac{x^2}{a^2} - \frac{y^2+z^2}{b^2} = 1$,可得:
$$\left(\frac{1}{a^2} - \frac{k^2}{b^2}\right)x^2 - \frac{2km}{b^2}x + \frac{m^2}{b^2} = 1 + \frac{z^2}{b^2}$$
进一步整理可得:
$$\left(\frac{1}{a^2} - \frac{k^2}{b^2}\right)\left(x - \frac{a^2 km}{b^2 - a^2 k^2}\right)^2 - \frac{z^2}{b^2} = 1 + \frac{m^2}{b^2 - a^2 k^2}$$
注意到:$|k| < \frac{b}{a}$,故:$b^2 - a^2k^2 > 0$,从而上式中$\left(\frac{1}{a^2} - \frac{k^2}{b^2}\right)>0$,等式右侧$1 + \frac{m^2}{b^2 - a^2 k^2} > 1$,故:上式可以写为:
$$\frac{(x-m')^2}{(a')^2} - \frac{z^2}{(b')^2} = 1$$
其中:$m' = \frac{a^2 km}{b^2 - a^2 k^2}$, $a' = \sqrt{\frac{a^2 b^2}{b^2-a^2 k^2}\left(1+\frac{m^2}{b^2-a^2k^2}\right)}$,$b' = \sqrt{b^2 \left(1 + \frac{m^2}{b^2 - a^2 k^2}\right)}$
同时还需要满足$y=kx+m$。
示例:以$x^2 - y^2 - z^2 = 1$被平面$x+2y=0$切割为例,可以绘制出解如下
图中绿色曲面即为$\frac{(x-m')^2}{(a')^2} - \frac{z^2}{(b')^2} = 1$表示的曲面。

参考

为 Blogger HTML 编辑页面添加代码标签的快捷方式

动机

在blogger中写博客的时候经常需要插入代码,通过在HTML编辑模式下为代码段添加<code>...</code>标签实现,一旦插入代码较多这个过程就显得比较繁琐了;因此考虑开发一个chrome的插件实现这个功能。主要的需求描述如下:选中需要添加代码HTML标签的代码段,右键菜单中显示需要添加代码段的模式,点击后将添加了HTML标签的代码段替换选中的原代码段

Demo


设计思路

预期实现的功能:为所选的可编辑内容弹出右键菜单,根据相应的标签选择替换所选内容。
为了实现这个功能,首先要对chrome插件的架构有一定的了解。下图给出了Chrome插件的插件的一个基本框架图。(由来源作者总结,感觉很清晰)
值得注意的是:Chrome的插件是有一个独立的运行环境的,可以是popup或者background,均可以是由HTML/CSS/JavaScript编写,用于呈现插件的显示或者功能。而此外Chrome还提供了content script的方式,用于在“匹配的”网页中注入(injection)content script脚本,从而获取或者操作页面上的DOM(Document Object Model)对象。注意:处于安全性的考虑,content script只对页面上的DOM对象有操作的权限,而对页面中的JavaScript脚本或其中出现的变量等均无访问的权限。
Message机制:目前看来,content script与插件之间是相互隔离的,无法交互;而实际上Chrome还提供了一个Message的方法为content script与插件之间提供了通信的方式。例如:可以在content script中发送消息(sendMessage),而在background script中注册该消息的监听(onMessage.addListener),如此便可以实现content script通知background script的需求了;反之类似。
来源: Chrome插件(Extensions)开发攻略
插件框架:有了以上对Chrome插件架构的基本了解以后,就可以设计本插件的框架图了,如下图所示。在extension的background页面调用了contextMenus这个API将插件功能添加到右键菜单中,为“selection”类型的内容(DOM对象)创建了该插件的右键菜单,并且在创建时绑定了“onclick”方法,在其中调用了sendMessage方法,以此通知content script当前选择了需要替换的内容以及需要添加的标签类型。另一方面,在content script中,注册了onMessage方法,从而可以监听background中发送的消息。在onMessage方法中,实现了对“selection”内容的添加标签后替换的功能。
设计思路:插件框架图

实现

根据以上的设计思路,完成代码如下。(完整项目地址:CodeTag)
Chrome 插件下载地址: Code Tag
  • manifest.json
  • {
        "name": "Code Tag",
        "description": "This extension helps add html tag for editable selected context",
        "version": "0.2",
        "permissions": [
            "contextMenus"
        ],
        "content_scripts": [{
                "matches": ["<all_urls>"],
                "js": ["addTag.js"]
            }
        ],
        "background": {
            "scripts": [
                "background.js"
            ]
        },
        "icons": {
            "128": "icon.png"
        },
        "manifest_version": 2
    }
  • background.js
  • // parent menu
    var parent = chrome.contextMenus.create({
            "title": "Code Tag",
            "contexts": ["selection"]
        });
    
    // sub-menu for block style
    chrome.contextMenus.create({
        "title": "Block",
        "parentId": parent,
        "contexts": ["selection"],
        "onclick": function (info, tab) {
            if (info.editable) {
                chrome.tabs.query({
                    "active": true,
                    "currentWindow": true
                }, function (tabs) {
                    chrome.tabs.sendMessage(tabs[0].id, {
                        // codetag message, indicating block style
                        "codetag": "block"
                    });
                });
            }
        }
    });
    
    // sub-menu for inline style
    chrome.contextMenus.create({
        "title": "Inline",
        "parentId": parent,
        "contexts": ["selection"],
        "onclick": function (info, tab) {
            if (info.editable) {
                chrome.tabs.query({
                    "active": true,
                    "currentWindow": true
                }, function (tabs) {
                    chrome.tabs.sendMessage(tabs[0].id, {
                        // codetag message, indicating inline style
                        "codetag": "inline"
                    });
                });
            }
        }
    });
  • addTag.js (content script)
  • // trancode "<" and ">" in code snippet into html coding
    function transCode(text) {
        var newStr;
        newStr = text.replace(/</g, "<");
        newStr = newStr.replace(/>/g, ">");
        return newStr;
    }
    
    // register listener on message, fired when a sendMessage called in background
    // in this function, selection is recognized and replaced with tag added context
    // different tags are determined by the message sent from background
    chrome.extension.onMessage.addListener(function (message, sender, callback) {
        // get selection
        var sel = window.getSelection();
        var codeWithTag;
    
        if (message.codetag == "block") {
            codeWithTag = "<pre><code>" + transCode(sel.toString()) + "</code></pre>";
        }
        if (message.codetag == "inline") {
            codeWithTag = "<code>" + transCode(sel.toString()) + "</code>";
        }
        var elem = document.activeElement;
        var start = elem.selectionStart;
        var end = elem.selectionEnd;
        elem.value = elem.value.slice(0, start) + codeWithTag + elem.value.substr(end);
        // Set cursor after selected text
        elem.selectionStart = start + codeWithTag.length;
        elem.selectionEnd = elem.selectionStart;
    });
目前实现的功能是两个,分别可以添加<code>...</code> 或者<pre><code>...</code></pre>标签,从而实现Inline(嵌入行内)和Block(代码块)模式。(:为了实现语法高亮,可以参考之前的文章:使用 highlight.js 高亮博文中的代码

遇到的问题

1. getSelection无法获取预期的内容
起初,因为没有搞清楚Chrome插件的架构,导致踩了不少雷,其中就包括遇到getSelection方法无法获取预期内容的问题;最初设计插件的时候只考虑了background,而未添加content script,这样一来实际上是无法通过JavaScript代码访问到页面内容的。而getSelection是属于JavaScript中的方法。准确来说,在background中调用getSelection也只是在background的页面中进行操作,也就是说如果访问DOM对象,例如document,指代的是background页面,而我当时以为是当前的网页,这也就造成了getSelection无法获取预期内容的现象。

2. info.selectionText 将换行符替换为空格
接上一个问题,由于无法获取getSelection的预期内容,那么后续的操作也就无从进行下去。改变思路找到Chrome的contextMenus中的info.selectionText属性,可以获取在菜单创建时所选择的文本。看上去可以解决需求。但是当选中的文本中出现换行符时,就不能如愿了,换行符被替换为空格。这个方案再次失效。其中selectionText将换行符替换为空格的原因可能在于Chrome在菜单的显示中提供了“%s”直接转换选中文本的方式,如果保留换行符那么在菜单显示的时候就不方便了。

3. 插件的Debug窗口无法看到content script
最终找到了 Chrome插件(Extensions)开发攻略 这篇文章,对Chrome插件的架构有了重新的认识,意识到可以通过content script实现需求。与此同时也了解了Chrome的Debug工具。但是在调试的过程中又遇到了新的问题。原本是在插件的背景页启动了Debug页面,而在其中的content script部分并未发现所写的content script代码,无从调试。原因在于content script代码是注入到匹配的页面中,而不是插件的背景页,所以要调试content script需要在网页中进行审查(Inspector),启动Debug。

4. contextMenus同时满足两个条件
需求中是要对可编辑editable)的选中内容selection)进行替换,contextMenus可以根据点击发生的对象类型弹出相应的右键菜单,并且可以对不同的对象类型进行“OR”操作,但是并不支持“AND”操作,因此仅对于可编辑的选中内容右键弹出该插件菜单的操作是不能“直接”实现的。简单地变通方式可以如下:在contextMenus中只关注“selection”类型的对象,并且检查对象的“info.editable”属性进行判断,如果为真则触发sendMessage方法,否则不进行处理。

参考

Python 正则表达式

语法表

语法说明表达式实例完整匹配的字符串
字符
一般字符匹配自身abcabc
.匹配任意除换行符“\n”外的字符。
在DOTALL模式中也能匹配换行符。
a.cabc
\转义字符,使后一个字符改变原来的意思。
如果字符串中有字符*需要匹配,可以使用\*或者字符集[*]。
a\.c
a\\c
a.c
a\c
[...]字符集(字符类)。对应的位置可以是字符集中任意字符。
字符集中的字符可以逐个列出,或者给出范围,如[abc]或
[a-c]。第一个字符如果是^则表示取反,如[^abc]表示不是
abc的其他字符。
所有的特殊字符在字符集中都失去其原有的特殊含义。在字
符集中如果要使用 ]、-或^,可以在前面加上转义字符(\),
或把 ]、-放在第一个字符,把^放在非第一个字符。
a[bcd]eabe
ace
ade
预定义字符集(可以写在字符集[...]中)
\d数字:[0-9]a\dca1c
\D非数字:[^\d]a\Dcabc
\s空白字符:[<空格>\t\r\n\f\v]a\sca c
\S非空白字符:[^\s]a\Scabc
\w单词字符:[A-Za-z0-9]a\wcabc
\W非单词字符:[^\w]a\Wca c
数量词(用在字符或(...)之后)
*匹配前一个字符0或无限次。abc*ab
abccc
+匹配前一个字符1次或无限次。abc+abc
abccc
?匹配前一个字符0次或1次。abc?ab
abc
{m}匹配前一个字符m次。ab{2}cabbc
{m,n}匹配前一个字符m至n次。
m和n可以省略:若省略m,则匹配0至n次;若省略n,则匹
配m至无限次。
ab{1,2}cabc
abbc
*?, +?, ??
{m,n}?
使*, +, ?, {m,n}变成非贪婪模式abc{2,4}?abcc
边界匹配(不消耗待匹配字符串中的字母)
^匹配字符串开头。
在多行模式中匹配每一行的开头。
^abcabc
$匹配字符串末尾。
在多行模式中匹配每一行的末尾。
abc$abc
\A匹配字符串开头(对多行也仅匹配第一行开头)。\Aabcabc
\Z匹配字符串末尾(对多行也仅匹配最后一行末尾)。abc\Zabc
\b匹配\w和\W之间的字符。\bfoo\bfoo
bar foo bar
(foo)
foo2 (not matched)
\B匹配\w字符,但是不位于单词的开头或结尾。py\Bpython (matched)
py 3 (not matched)
逻辑、分组
|代表左右表达式任意匹配一个。
总是先尝试匹配左边的表达式,一旦成功则跳过匹配
右侧的表达式。如果 | 未被包括在 () 中,
则它的范围是整个正则表达式。
abc|defabc
def
(...)被括起来的表达式将作为分组,从表达式左边开始每遇到一
个分组的左括号 '(' ,编号+1.
另外,分组表达式作为一个整体,可以后接数量词。
表达式中的 | 仅在该组中有效。
(abc){2}
a(123|456)c
abcabc
a456c
(?P<name>...)分组,除了原有的编号外再额外指定一个别名(name)(?P<id>abc){2}abcabc
\<number>引用编号为<number>的分组匹配到的字符串。(\d)abc\11abc1
5abc5
(?P=name)引用别名为<name>的分组匹配到的字符串。(?P<id>\d)abc(?P=id)1abc1
5abc5
特殊构造(不作为分组,即不增加分组号)
(?:...)(...)的不分组版本(?:\d)(\d)abc\112abc2 (matched)
12abc1 (not matched)
(?#...)#后为注释内容,不形成匹配模式(?#comment)abcabc
(?=...)之后的字符串需要匹配表达式中的内容。
不消耗字符串内容。
abc(?=123)abc123 (matched)
匹配结果为 abc,
而不是 abc123。
abc12 (not matched)
(?!...)之后的字符串不是表达式中的内容,才匹配。
不消耗字符串内容。
abc(?!123)abc123 (not matched)
abc12 (matched)
匹配结果为 abc,
而不是 abc12。
(?<=...)之前的字符串需要匹配表达式中的内容。
不消耗字符串内容。
(?<=123)abc123abc (matched)
(?<!...)之前的字符串不是表达式中的内容,才匹配。
不消耗字符串内容。
(?!123)abc12abc (matched)
(?(id/name)
yes-pattern|
no-pattern)
类似C语言中的三元操作符(condition ? x : y),此处的含义
即当第id个分组或者名称为name的分组匹配到结果的时候
就采用yes-pattern,否则采用no-pattern,
其中no-pattern可以省略
(<)?(\w+@\w+(?:\.\w+)+)(?(1)>)<user@host.com>
user@host.com
---------------------------
<user@host.com
(not matched)

Python测试

Python中的正则表达式库为 re 。而常用的函数如下
  • compile
  • match
  • search
其中compile函数负责根据生成正则表达式生成相应的匹配模式,而matchsearch函数则根据匹配模式对目标字符串进行匹配。matchsearch的区别在于match是从字符串的开头匹配,相当于对匹配模式强制实施了\A,而search可以从字符串的任意位置匹配正则表达式。
import re

pattern = re.compile(r'world')
match1 = pattern.match('hello, world!')
match2 = pattern.search('hello, world!')

if match1:
    print(match1.group())
else:
    print("No match")

if match2:
    print(match2.group())
else:
    print("No match")
输出结果如下:
No match
world

Raw string

注意到上面的例子中声明pattern时使用了如下的方式 pattern = re.compile(r'world'),其中的 r'...' 表示字符串为 raw string,即不对反斜杠 (\) 做转义处理。例如:r'\n' 表示的正是两个字符 \n 的组合,而如果不声明为raw string的话 '\n' 表示的就是换行符。Raw string的最大好处在于声明正则表达式时可以简化书写。举例说明如下:
pattern1 = re.compile('\\w') # 需要第一个反斜杠对第二个反斜杠转义
pattern2 = re.compile(r'\w') # 反斜杠就是其本来的含义,因此无需两个

综合测试

结合前面的语法表,此处给出一个简单的综合测试案例。要求是从字符串中筛选出电子邮箱地址。实现的方式如下:
(<)?(\w+@\w+(?:\.\w+)+)(?(1)>)
用到的语法包括 \w, +, (?:...), (...), ?, (?(id) yes-pattern | no-pattern)
分析:我们知道邮箱的格式一般为 xxx@yyy.zzz
首先关注以上正则表达式的中间部分 (\w+@\w+(?:\.\w+)+)@前的\w+表示至少有一个字母或数字,指代了xxx部分;而@后的\w+指代了yyy部分;(?:\.\w+)+表明了.zzz模式至少有一个(并且不记入分组计数),例如:.com又或者.edu.cn
然后,关注以上表达式首末的语法,(<)? 表示左尖括号没有或者仅有一个,并且由于是第一个分组,所以分组的序号为1;而末尾的语法 (?(1)>) 是 (?(id) yes-pattern | no-pattern) 的省略写法,省略了no-pattern部分,以第一个分组的匹配结果为条件,若找到左尖括号,则相应整体的正则表达式最后会添加上右尖括号从而形成配对;否则无需指定多余的匹配模式;如此一来便可实现对以下两种形式的邮箱地址进行匹配 user@host.com 或者 <user@host.com>

测试源码

更多详细的测试案例参见下方测试代码
Python-Study/regex_study.py

参考