前些天帮hxd写了一个脚本,因为这些天考试什么的一直没空,考完试了抽空来分享一下。
需求: 自动爬取静态htm页面的某些字段值放入excel表中。比如说一个某人期末考试成绩的静态页面,就需要自动把一个学校上万份的成绩直接导入excel表。
分析: 我首先想到的就是使用C++写。第一是因为C++基础相对其它较好,python很久没用了。第二是主要原因,python脚本发给朋友用需要有python环境,而C++生成了exe可以直接发过去使用。
整体思路: 将生成的exe可执行文件与htm页面放在同一个文件路径下,双击运行程序即可。那么首先我们需要获取文件的当前目录。其次获取该目录下的所有文件。然后读取指定文件(这里就是htm文件)的内容,然后使用正则表达式匹配html语言标签的值,再写入excel。
getcwd
首先第一个问题是,如何获取当前程序所在的路径。gcc提供了getcwd直接获取当前路径,需要注意的是,当我想查看其底层源码时,却只能找到下面的代码:
extern inline __attribute__((__always_inline__)) char *
getcwd (char *__buf, size_t __size)
{
if (__ssp_bos (__buf) != (size_t) -1 && __size > __ssp_bos (__buf))
__chk_fail ();
return __getcwd_alias (__buf, __size);
}
这是因为,getcwd是由编译器gcc提供的内置函数,并不是C库函数。
所以,如果我们用llvm等其它编译器进行编译时,这类函数是用不了的。__getcwd_alias的具体函数就看不到,只能溯源到一些宏定义。不过我们还是大概能看懂什么意思,第一次参数是一个指针,然后第二个参数是指向的内存区域的大小,如果判断大小不对就不会报错。所以我实际的用法是:
char buffer[MAX_P];
getcwd(buffer, MAX_P);
extern char *__SSP_REDIRECT (__getcwd_alias,
(char *__buf, size_t __size), getcwd);
# define __SSP_REDIRECT(name, proto, alias) \
name proto __asm__ (__SSP_ASMNAME (#alias))
# define __SSP_ASMNAME(cname) __SSP_ASMNAME2 (__USER_LABEL_PREFIX__, cname)
# define __SSP_ASMNAME2(prefix, cname) __SSP_ASMNAME3 (prefix) cname
# define __SSP_ASMNAME3(prefix) #prefix
上面的代码并没有看懂,不过感觉其底层实现和汇编有关系。想要具体了解,可能得去看看gcc中patch的实现了,瞄了一眼,不想再看了…贴出链接
传送门
GetAllFiles
然后我们需要获取当前路径下的所有文件
void GetAllFiles( string path, vector<string>& files)
{
long hFile = 0;
//文件信息
struct _finddata_t fileinfo;//用来存储文件信息的结构体
string p;
if((hFile = _findfirst(p.assign(path).append("\\*").c_str(),&fileinfo)) != -1)
{
do
{
if((fileinfo.attrib & _A_SUBDIR)) //如果查找到的是文件夹
{
if(strcmp(fileinfo.name,".") != 0 && strcmp(fileinfo.name,"..") != 0) //不能是当前目录和上一级目录
{
files.push_back(p.assign(path).append("\\").append(fileinfo.name) );
GetAllFiles( p.assign(path).append("\\").append(fileinfo.name), files ); //递归搜索
}
}
else //如果查找到的不是是文件夹
{
files.push_back(p.assign(fileinfo.name) ); //保存文件名
}
}while(_findnext(hFile, &fileinfo) == 0);
_findclose(hFile); //结束查找
}
}
首先需要是使用 _finddata_t 来存储文件信息,分别表示:文件的类型,文件的创建时间,文件最后一次被访问时间,文件最后一次修改时间,文件的大小,文件的名称。
#define _finddata_t _finddata32_t
struct _finddata32_t {
unsigned attrib;
__time32_t time_create;
__time32_t time_access;
__time32_t time_write;
_fsize_t size;
char name[260];
};
接着使用_findfirst查找文件。_findfirst起始是_findfirst32的宏定义,而_findfirst32函数的第一个参数是const char * 类型的,这也是为什么在传参时要加一个c_str()函数,就是把string类型转换成const char * 类型。该函数返回查找成功的文件句柄,把文件信息存储在结构体中,查找失败返回-1.
// io.h
_CRTIMP intptr_t __cdecl _findfirst32(const char *_Filename,struct _finddata32_t *_FindData);
这里的_Filename路径中可以包括*这种通配符,这样就可以方便地表示某目录下地所有文件。显然,这里可以直接定义文件的类型,不需要后面再加条件进行判断htm文件,但是为了保持函数的统一性,就没有更改,也没有在函数上加参数。另外可惜的是,在io.h定义的内容无法搜索到其源码实现。
同时在io.h中宏定义了一些值来表示文件的类型。
#define _A_NORMAL 0x00 //普通文件
#define _A_RDONLY 0x01 //只读
#define _A_HIDDEN 0x02 //隐藏
#define _A_SYSTEM 0x04 //系统
#define _A_SUBDIR 0x10 //文件夹
#define _A_ARCH 0x20 //存档
所以整体的思路就是,利用_findfirst搜索第一个文件,然后判断是否是文件夹进行递归查询,再用得到的文件句柄使用_findnext继续搜索,最后使用_findclose结束查询。
正则表达式进行匹配
然后就是读取这些文件,并利用正则表达式进行匹配。
正则如何匹配就不讲了,网上一大堆都能搜到。这里给出一个例子。
regex tableReg("<SPAN style=\"COLOR: teal\">姓名 </SPAN>[^ -~]{2,}");
auto ret = regex_search(p,m,tableReg);
if(ret2){
string oo = m2[0];
e.WriteCell(ans, 1, oo.substr(19).data());
cout << oo.substr(19).data() << endl;
}
我就是用上面这种方式进行匹配特定标签值的。因为我提前查看的所有的htm页面的源码,他们都使用的是同一种格式,所以为了方便我直接计算字符位数截取想要的数值,但是显然这不是一种聪明的方法,有更好的方法欢迎讨论。
写excel
刚开始说实话没想到写excel会这么麻烦,后来在网上看了一圈才明白,excel不像普通文件可以直接读写,网上的大多数方案是导入excel的库,常见的是odbe,ole等方式。但是其实这样做起来是挺麻烦的,短时间内不想深入探究。于是这里想到一个曲线救国的方案,写csv,然后再讲csv文件复制到excel中,反正只是一个自动化实现功能的小脚本。
于是可以直接写一个读写csv的类,这比直接写excel简单多了。
class ExcelProcess
{
public:
ExcelProcess(){pf=NULL;};
~ExcelProcess(void){ if (pf) EndWrite(); };
void WriteCell(ushort row, ushort col, const char *value)
{
ushort iLen = (ushort)strlen(value);
ushort clData[] = { 0x0204, ushort(8 + iLen), row, col, 0, iLen };
WriteArray(clData, 12);
WriteArray(value, iLen);
}
void WriteCell(ushort row, ushort col, int value)
{
ushort clData[] = { 0x027E, 10, row, col, 0 };
WriteArray(clData, 10);
int iValue = (value << 2) | 2;
WriteArray(&iValue, 4);
}
void WriteCell(ushort row, ushort col, double value)
{
ushort clData[] = { 0x0203, 14, row, col, 0 };
WriteArray(clData, 10);
WriteArray(&value, 8);
}
void WriteCell(ushort row, ushort col)
{
ushort clData[] = { 0x0201, 6, row, col, 0x17 };
WriteArray(clData, 10);
}
bool BeginWrite(const char *fileName)
{
pf = fopen(fileName, "wb+");
if (!pf) return false;
ushort clBegin[] = { 0x0809, 0x08, 0x0, 0x10, 0x0, 0x0 };
WriteArray(clBegin, 12);
return true;
}
void EndWrite()
{
ushort clEnd[] = { 0x0A, 0x0 };
WriteArray(clEnd, 4);
fclose(pf);
pf = 0;
}
bool IsOpen()
{
if (pf!=NULL)
{
return true;
}
else
{
return false;
}
}
void WriteArray(const void *value, ushort len)
{
if (pf)
fwrite(value, 1, len, pf);
}
private:
FILE *pf;
};
这个类可以直接拿去用。
编码问题
当我以为所有的问题都解决时,我使用编译运行,却发现写入的值出现了乱码,于是首先在控制台中对读取的文件内容进行输出,发现在这里就出现乱码。
为了解决这个问题,首先需要一些编码的基础知识,看了不少文章,这里直接给总结一下:
- 先从ascii编码开始,ascii使用7位来表示128个基础字符,如大小写英文字母等等
- 中国人通过对ascii编码进行扩展,发明了 GB2312 编码,以表示6000多个常用汉字。
- 但是汉字很多,包括繁体和各种字符,于是产生了 GBK 编码,它包括了 GB2312 中的编码,同时扩充了很多。所以GB2312是GBK编码的子集。
- 中国是个多民族国家,各个民族几乎都有自己独立的语言系统,为了表示那些字符,继续把 GBK 编码扩充为 GB18030 编码。
- 然后iso为了统一提出了UNICODE编码。但是起始准确的说,UNICODE是字符集,能够表示任何一种字符,但只是提供字符的二进制码,没有规定如何编码。
- 然后出现了3中对unicode进行编码的方式,分别是utf-8,utf-16le,utf-16be。简单的说就是,unicode编码分为3中,分别是utf-8、utf-16le,utf-16be。
- 不得不提的是BOM标志,即byte order mask. 就是放在文件的最开始位置,用来表示编码的顺序,这是一种很容易理解的思维方式。
- utf-16 这里的16是指16位,也就是2字节。即每两个字节编码一个字符。(离谱的是就算是英文字符也是要两个字节)。然后utf-16是必有BOM的,分别是LE(0xFF 0xFE) 和 BE(0xFE 0xFF)。这也是为什么utf-16有be和le了。这里的be和le的意思是big endian, low endian,即大段和小端的意思。
- utf-8分为有BOM和没有BOM的。utf-8是不定长的,可能是1-3字节。有BOM的utf-8的BOM为3个字节的前缀0xEE BB EF。
- 其实正常情况下utf-8编码是不带BOM的,但是是微软的习惯,为了区分uft-8和ascii等编码,一个原因是,英文大小写等基础字母在utf-8和ascii编码中是一样的。同时也是微软的习惯,默认把utf-16le成为unicode编码。
有了以上基础知识,相信一定对编码有了一个宏观的了解,至少不会凌乱了。然后下面回归我们的问题,基于上面的知识,我们可以做出以下分析:
- vscode 在写代码的时候默认是utf-8,这在右下角可以看到。
- 出现乱码问题的原因是:
当我试图用vscode打开htm文件时,发现编码是utf-16le。仔细看看上面的乱码,其实就是两个字节编码的原因,导致字符变宽了。
而代码的编写编码是utf-8,这就导致了正则表达式中的汉字无法匹配的问题,以及控制台终端的编码是gbk的原因,所以显示乱码,以及写入csv的汉字出现乱码。 - 统一编码来解决乱码问题。首先的想法是,因为htm文件打开的默认编码是utf-16le,所以不如把代码编码和控制台编码全部改成utf-16le。vscode里面更改编码很简单,右下角直接改然后重新写代码里面的中文即可。但是在终端窗口中却不好改。网上给出的两种更改方式其实本质都一样,就是利用chcp命令更改,在存在的缺点是,不够自动化。当我把脚本给朋友时,我无法做到直接使用代码更改他的环境上的控制台的编码。所以更好的方式就是,既然windows默认是gbk,那就全部统一为gbk编码。
- 如何把文件从utf-16le转换成gbk编码呢?最简单的方式就是用windows记事本打开文件,保存的时候选择编码。
可以看到,这里出现的编码,正好对应上面讲过的编码,utf-8带物料清单就是带BOM头的utf-8.
那么ANSI是什么编码呢?
----引用[ANSI是什么编码](https://blog.csdn.net/imxiangzi/article/details/77370160) 所以其实我们就可以暂且认为,在我们的环境下,ANSI编码就是GBK编码,但是我们不可能一个一个打开文件转换编码吧,我们需要用代码的方式转换文件的编码。
文件编码转换为ANSI编码
/*
* 将路径fpath所指的文件从各种编码格式,转换为ANSI格式
*
* Copyright (c) 2013 赵子清, All rights reserved.
*
*/
#include <bits/stdc++.h>
#include <windows.h>
#define CODE_FORMAT_ANSI 1
#define CODE_FORMAT_UTF8 2
#define CODE_FORMAT_UNICODE_LE 3
#define CODE_FORMAT_UNICODE_BE 4
typedef int ErrorCode;
#define ERR_OK 0
#define ERR_FILE_OPEN_FAILED 1001
#define SWAP16(x) \
((((x) & 0x00ff) << 8) | \
(((x) & 0xff00) >> 8) )
#define SWAP32(x) \
((((x) & 0x000000ff) << 24) | \
(((x) & 0x0000ff00) << 8) | \
(((x) & 0x00ff0000) >> 8) | \
(((x) & 0xff000000) >> 24) )
#define SAFE_DELETE(x) if((x)!=0) {delete[] (x); (x) = 0; }
ErrorCode ConvertFormat(const char* fpath)
{
assert(fpath != 0);
FILE* fp = ::fopen(fpath, "rb");
if(fp == NULL)
return ERR_FILE_OPEN_FAILED;
int fmtFlag;
int fmt = CODE_FORMAT_ANSI;
::fread(&fmtFlag, sizeof(int), 1, fp);
::fclose(fp);
fmtFlag = SWAP32(fmtFlag);
if((fmtFlag & 0xffffff00) == 0xefbbbf00)
fmt = CODE_FORMAT_UTF8;
else if((fmtFlag & 0xffff0000) == 0xfffe0000)
fmt = CODE_FORMAT_UNICODE_LE;
else if((fmtFlag & 0xffff0000) == 0xfeff0000)
fmt = CODE_FORMAT_UNICODE_BE;
if(fmt == CODE_FORMAT_ANSI)
return ERR_OK;
fp = ::fopen(fpath, "rb");
char* txt = 0;
wchar_t* wtxt = 0;
long flen = 0L;
::fseek(fp, 0L, SEEK_END);
flen = ftell(fp);
::rewind(fp);
if(fmt == CODE_FORMAT_UTF8)
{
txt = new char[flen+1];
::fread(txt, 1, flen, fp);
txt[flen] = '\0';
}
else if(fmt == CODE_FORMAT_UNICODE_LE || fmt == CODE_FORMAT_UNICODE_BE)
{
wtxt = new wchar_t[flen/2 +1];
::fread(wtxt, 2, flen/2, fp);
if(fmt == CODE_FORMAT_UNICODE_BE)
{
for(int i=0; i < flen/2; i++)
wtxt[i] = SWAP16(wtxt[i]);
}
wtxt[flen/2] = L'\0';
}
::fclose(fp);
int nLen;
wchar_t* pwstr = 0;
char* pstr = 0;
switch (fmt)
{
case CODE_FORMAT_UTF8:
nLen = ::MultiByteToWideChar(CP_UTF8, 0, txt+3, -1, NULL, 0);
pwstr = new wchar_t[nLen+1];
nLen = ::MultiByteToWideChar(CP_UTF8, 0, txt+3, -1, pwstr, nLen);
nLen = ::WideCharToMultiByte(CP_ACP, 0, pwstr, -1, NULL, 0, NULL, NULL);
pstr = new char[nLen];
::memset(pstr, 0, nLen);
nLen = ::WideCharToMultiByte(CP_ACP, 0, pwstr, -1, pstr,
nLen, NULL, NULL);
break;
case CODE_FORMAT_UNICODE_LE:
case CODE_FORMAT_UNICODE_BE:
nLen = ::WideCharToMultiByte(CP_ACP, 0, wtxt+1, -1, NULL, 0, NULL, NULL);
pstr = new char[nLen];
::memset(pstr, 0, nLen);
nLen = ::WideCharToMultiByte(CP_ACP, 0, wtxt+1, -1, pstr,
nLen, NULL, NULL);
break;
default:
break;
}
fp = ::fopen(fpath, "wb");
::fwrite(pstr, 1, nLen-1, fp);
::fclose(fp);
SAFE_DELETE(txt);
SAFE_DELETE(wtxt);
SAFE_DELETE(pstr);
SAFE_DELETE(pwstr);
return ERR_OK;
}
代码引用自赵子清的博客
其本质是使用WideCharToMultiByte函数,在windows.h的头文件中,不具有通用性。
首先,需要知道,::可以表示全局作用域,这是因为有可能定义的局部函数或者变量和全局的名字一样,编译器可能找不到,所以加上::来确定是全局的函数与变量。
其次,读取文件的第一个字节的内容,根据BOM的内容,判断是哪种编码方式。
#define SWAP32(x) \
((((x) & 0x000000ff) << 24) | \
(((x) & 0x0000ff00) << 8) | \
(((x) & 0x00ff0000) >> 8) | \
(((x) & 0xff000000) >> 24) )
fmtFlag = SWAP32(fmtFlag);
if((fmtFlag & 0xffffff00) == 0xefbbbf00)
fmt = CODE_FORMAT_UTF8;
else if((fmtFlag & 0xffff0000) == 0xfffe0000)
fmt = CODE_FORMAT_UNICODE_LE;
else if((fmtFlag & 0xffff0000) == 0xfeff0000)
fmt = CODE_FORMAT_UNICODE_BE;
这里SWAP32其实就是交换32位的高低16位,比如0xABCDEF00就会转换为0x00EFCDAB,就是大小端的转换。但是其实这里我认为不需要转换也是可以的,后面作&运算时判断低位即可。
接着,求得file的文件大小。其中fseek定位到seek_end即文件末尾,然后使用ftell求得fp指针所在位置,即flen为文件的大小。
::fseek(fp, 0L, SEEK_END);
flen = ftell(fp);
然后再利用rewind讲fp重新指向文件的最开头。
::rewind(fp);
接着就需要读文件了。如果读入的文件是utf-8的形式,直接读,一次一个字节,flen的长度。
if(fmt == CODE_FORMAT_UTF8)
{
txt = new char[flen+1];
::fread(txt, 1, flen, fp);
txt[flen] = '\0';
}
如果是unicode的形式,因为两个字节编码,所以使用wchar_t来存,因为wchar_t其实就是unsigned short 两字节,并且长度减半,接着大小端转换,把所有大段全部转换成小端,这样utf-16be就也转换成了utf-16le,这样就全部当作小端来处理了。
typedef unsigned short wchar_t; //unsigned short两字节
else if(fmt == CODE_FORMAT_UNICODE_LE || fmt == CODE_FORMAT_UNICODE_BE)
{
wtxt = new wchar_t[flen/2 +1];
::fread(wtxt, 2, flen/2, fp);
if(fmt == CODE_FORMAT_UNICODE_BE)
{
for(int i=0; i < flen/2; i++)
wtxt[i] = SWAP16(wtxt[i]);
}
wtxt[flen/2] = L'\0';
}
读完文件内容之后,就开始转码了,就是利用WideCharToMultiByte这个由windows提供的api进行转换。utf-8转换成ansi就是先把utf-8转换成utf16,再转成ansi,所以我们直接看utf-16
case CODE_FORMAT_UNICODE_LE:
case CODE_FORMAT_UNICODE_BE:
nLen = ::WideCharToMultiByte(CP_ACP, 0, wtxt+1, -1, NULL, 0, NULL, NULL);
pstr = new char[nLen];
::memset(pstr, 0, nLen);
nLen = ::WideCharToMultiByte(CP_ACP, 0, wtxt+1, -1, pstr,
nLen, NULL, NULL);
break;
这里简单的讲就是, ::WideCharToMultiByte(CP_ACP的意思是,它的意思是:我已经是utf-16了,我要转化成cp_ acp(ansi)。
同样的,utf-8的步骤中,后两步和这个是一样的,前两步就是先把utf-8转成utf-16 (WideChar 顾名思义就是宽字符,就是两个字节,utf-16两个字节编码)。::MultiByteToWideChar(CP_UTF8 就是意思为: 我现在已经是utf-8了,要转成utf-16.
至此,全部结束,就可以没有乱码地提取出htm文件中相应的属性值了。相关的资源也上传了,可以拿来参考。点击下载
References:
- https://www.cnblogs.com/zzqcn/archive/2013/04/25/3043730.html
- https://www.cnblogs.com/ranjiewen/p/5770639.html
- http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
- https://blog.csdn.net/imxiangzi/article/details/77370160
- https://blog.csdn.net/yu704645129/article/details/79907625
- https://blog.csdn.net/qq1623803207/article/details/89398435