狗生第一篇观剧感——大江大河

写了小半年的“日记”,实际算不得日记,因为没做到一日一记😂。开始习惯记录平时的所想所感。

刚好最近看到为改革开放40年献礼而不失有趣的国产剧《大江大河》,刚看了几眼就觉得此剧值得一看。看的过程中,经常自己抹眼泪😂,真是一个费纸的剧。所以想到可以边看边写一些情感波动和联系到的东西,应该是种不错的体验,所以就有了狗生的第一篇电视剧观看历程。(出于时间考虑,前25集1.5倍速观看,后面的2倍速观看。发现效果很不错)

导演真的太敬业了,此剧以上世纪七八十年代农村为主场景,从农村长大的我,剧中好多的场景,物件,人设,真的是一看就立马回到小时候的记忆,真棒👍。

人们成熟之后,最节俭的,不是金钱,而是情感,自己很少用,也不向别人索取。只存不取,攒得多了,偶尔在遇见一部电影,一首老歌的时候,倾泄而出。

——《半山文集》

不说了,上笔记。

这几天撸铁撸得有点过了,麒麟臂疼得不行,不码字了。最后以东宝书记的三轮车结束吧,快上车。

2018-2019

今年过得好快鸭🦆。来个日常年常简短总结。

今年也算是一个小的里程碑,在某些方面有了一些大的变化。值得庆祝。

在工作上,一直积极关注学习新的东西。16年刚毕业的一年,从前端到Node后端,到使用Vue,RN,Electron等开启全栈之路,开拓了较广的技术栈。到17年紧跟“潮流”,去做了半年多的小程序全栈,同时巩固了后端开发。至此貌似大前端的大多数东西都已经有过开发经验,勉强算的上伪全栈。作为一个好奇的人,从17年就开始关注区块链技术,入了币圈。并在17年底心血来潮,来了一波说走就走的裸辞,还拉上了几个垫背的几个好机油,一起拥抱百年一遇的泡沫狂欢技术革命。

事实上回头来看,这种骚操作果敢,带来的收益是极大的,年轻就要醒着拼。我不知道当初选择继续原公司的工作,现在是什么样。但裸辞后全身心投入区块链的学习,学go,学以太坊,学合约等等,学习过程中结识了很多优秀的人,来自各行各业的,有年薪百万的大佬,有清华的投行高材生,有手握矿机的机霸矿霸,有还在学校的大四学生,有以卡还卡的套现大佬,有出口成章的演讲家。大家聚在一起享受币圈的狂欢的同时,一起学习进步,真的是学到了很多东西。第一次步入这样一个个体差异极大的群体,真的是从每个人身上学到很多,受益匪浅。

蝴蝶效应,正是当初说走就走的裸辞,带来了结果颇丰的一年。

  • 首先是年薪完成了double,来到了一个不错的新集体中,结识了一群新的nice的同事,身边好多大厂出身的大佬,可以耳濡目染。
  • 在这半年中也享受了泡沫带来的狂欢,也经历了账户余额日浮动达到六位数的体验,价值观,投资观念显著升级。人常说币圈一年人间十年,一点也不夸张,收获许多宝贵的经历。
  • 同时开发了币圈的第一个以太坊小程序钱包,脑洞大开的加入了币红包功能,这也是目前为止最得意的一款产品。虽然现在被微信封了,但一个普通人,当有一天你发现你做的东西是这个领域的第一个时,这种成就感真爽。上线首日在币圈群里疯狂传播,大家完的不亦乐乎,赞不绝口。同时好几位大佬投来橄榄枝,虽然最终都没去。这可能是遗憾的一个点,如果视野再宽广点,想的再多点,把产品打磨升级,以当时的状况,在币圈融资不是不可能。其次投来的橄榄枝没有认真对待,不然现在可能都会是不同的结局。不过人生就是这样,你永远不知道那个选择是做好的。总的来时,快速学习掌握新技术,你就可以在新的领域做那个NO1。另外成就感可能是人达到高潮的另一种手段,而且是持续高潮。
  • 还有,就是买了矿机挖矿,之前都是用1070显卡挖的。心血来潮花了五六万买了几台BTM矿机,挖了小一个月体验了一把。中途还被朝阳群众和辅警各种上门问询233。也是很棒的一个体验。
  • 飘逸的长发也建成了寸头,用了一年的发胶发蜡扔掉了,自行理发一年后,现在开始去理发店了,而且基本上三周就去一次。人就是这样,没自己经历过的东西,别人说再多你也不会打心里认可,当自己留了辫子,留了长发,用了发胶,酷了一年之后,就会发现短发其实也不错,更舒服。
  • 之前一直鄙视皮鞋的我,竟然爱上了马丁靴,买了人生第一个皮靴,虽然刚开始穿挺累的,但还挺好看的哈哈哈。真是不同时段有不同的审美。
  • 最重要的,这一年没得啥病,健健康康。热领庆祝体重终于接近140斤,现在跟身高可以说很搭了,体重指数21.22,简直完美。对了,就是后半年脸上长痘消不下去,导致的后果就是大家都以为我是80后 = =,看上去老成是种怎样的体验……
  • 打了几个月的乒乓球,遇到一个旗鼓相当的对手,不加班的时候每天抽几个小时,打的香汗淋漓,爽!想当年驰骋高中的削球手又回来了😏
  • 这半年的厨艺也小有进步吧,就像是从初级转中级了一样,做菜时的心态稳的一批。
  • 参加了很多的聚会。以前是能避则避,过去一年,不管是家族的,朋友的,有尽量参加,其实没那么糟。
  • 开始主动和妹子说话聊天,这项弱势技能需要锻炼一下了。
  • 买了几种乐器,慢慢练练,应该是项不错的技能。
  • 对了,看了超级多的电影、纪录片,这一年真的是把电影看下了,各种类型的,还有好几个动漫、国产剧。影视源于作品,源于生活。书以历史地理心理学为主,为闲暇时间增加很多乐趣。
  • 学本学到一半,来年赶紧学完得了…车放着没人开也是糟心🌚只后悔当初没早点学了。
  • 最后,恭喜IG夺冠,虽然不是我心心念的OMG,但是毕竟从大一到现在六年的时间,带给了很多欢乐。虽然现在玩的越来越少了,但真的好像青春有个明显的结束标志了一样。慢慢的发现,有很多东西的快感高于游戏了。

就总结这么多吧。2018,不错的一年。

到展望2019年的时候了,按常理需要列个列表了,想了想还是算了。想做、要做的事有很多,需要一个列表来做规划。会列列表的人很多,心里能惦记着的不多,自己知道就行。给别人看的不叫计划,自己有期待就行。

祝大家新年快乐,新的一年首先强食自爱,其次奋不顾身,同时保持璞玉浑金。

图床从七牛迁移至腾讯COS

前言(吐槽):

之前收到邮件,七牛要回收什么测试域名,想着没啥影响。因为我绑定了备案域名。最近突然发现博客好多图片挂了。一看我备案了快五年的域名被取消备案了???而且七牛直接把测试域名删了…

我当初注册的就是个人性质博客类网站,五年时间网站一直正常运行,且内容性质从未改变。空壳网站?备案信息不准确?真是睿智 🖕

概览

以下操作在macOS下进行。所有命令的文档请参考qshell命令列表

主要流程: 下载七牛中的所有资源,上传至腾讯COS。由于七牛的测试域名已失效,原空间的资源无法直接下载,所以先新建一个空间(新空间测试域名30天有效期),将旧空间的资源全部转移至新空间,然后从新空间下载所有资源。

一、七牛资源从就空间转移至新空间

1、下载七牛的命令行工具qshell

2、解压、重命名、赋予qshell运行权限

前往目录

1
cd  /Users/ludis/Downloads/qshell-v2.3.4 && ll
1
2
3
4
5
6
7
8
➜  qshell-v2.3.4 ll
total 135824
-rwxr-xr-x@ 1 ludis staff 14M Dec 4 16:19 qshell_darwin_x64
-rwxr-xr-x@ 1 ludis staff 14M Dec 4 16:19 qshell_linux_x64
-rwxr-xr-x@ 1 ludis staff 12M Dec 4 16:19 qshell_linux_x86
-rwxr-xr-x@ 1 ludis staff 14M Dec 4 16:19 qshell_windows_x64.exe
-rwxr-xr-x@ 1 ludis staff 12M Dec 4 16:19 qshell_windows_x86.exe
➜ qshell-v2.3.4

重命名(可选)

1
mv qshell_darwin_x64 qshell

设置权限

1
chmod +x qshell

3、添加七牛账号

前往七牛 -> 个人中心 -> 密钥管理。查看AK、SK。

./qshell account [AK] [SK] [账户名(邮箱即可)]

4、导出已过期的bucket空间所有文件信息。

qshell listbucket A -o A.list.txt (A为空间名)

5、新建一个空间B,用于将已过期的A空间中的所有文件,转移至新的空间B。(新空间的域名30天有效)注意A、B空间需要在相同的地区才可以。

6、使用awk工具,将A空间的文件列表进行格式化。Mac下自带awk工具,其余平台自行安装。

此时A.list.txt中的内容格式如下:

1
2
3
4
5
6
...
images/react.png 340793 FoVpVxc12JXJawT0UdkRs7bHm3MS 15192693058673770 image/png 0
images/watermark.png 11567 FirWtot1NJVL-0bU-9VoN4yeQyLu 15192728052402421 image/png 0
postbg.jpg 105210 FlBqtLeiA1kTiQxRUSSapLep1utC 15185150032696840 image/jpeg 0
postcover.jpg 47446 Fltqi_cmpz09x8PV4y1yGYwqW_lr 15185152316033806 image/jpeg 0
...

使用awk命令格式化

1
2
touch list.txt
awk '{print $1}' A.list.txt > list.txt

这个命令意思是将A.list.txt文件中每行的第一个字段(文件名)分离出来,输出到list.txt文件中。

对应的list.txt格式如下:

1
2
3
4
5
6
7
...
images/postbg.jpg
images/react.png
images/watermark.png
postbg.jpg 105210
postcover.jpg
...

7、使用命令将A空间中的所有资源批量转移至新空间B中:

1
2
➜  qshell-v2.3.4 ./qshell batchcopy A B -i list.txt
<DANGER> Input hejhbd to confirm operation: hejhbd

根据提示输入字符串即可,次数刷新B空间即可看到,所有A空间的内容已经转移到B空间。

二、从七牛批量下载资源

1、创建下载用的配置文件batch_download.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
// dest_dir 文件储存路径-全路径
"dest_dir" : "/Users/ludis/Downloads/qshell-v2.3.4/downloads/B",
// 新建的空间名-B
"bucket" : "B",
"prefix" : "",
"suffixes" : "",
// B空间的测试域名
"cdn_domain" : "http://pji27eyb0.bkt.clouddn.com",
"referer" : "",
"log_file" : "download.log",
"log_level" : "info",
"log_rotate" : 1,
"log_stdout" : false
}

2、下载文件

执行./qshell qdownload -c 10 batch_download.conf。其中-c 10表示可以同时下载10个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜  qshell-v2.3.4 ./qshell qdownload -c 10 batch_download.conf
Writing download log to file download.log

Downloading 2018-09-26-a0KAWoB.jpg [1/76, 1.3%] ...
Downloading 2018-10-14-15382027090950.jpg [2/76, 2.6%] ...
Downloading 2018-10-14-15394888949656.jpg [3/76, 3.9%] ...
Downloading 2018-10-14-15394889958531.jpg [4/76, 5.3%] ...
Downloading 2018-10-14-15394901705197.jpg [5/76, 6.6%] ...
...
Downloading postbg.jpg [75/76, 98.7%] ...
Downloading postcover.jpg [76/76, 100.0%] ...

See download log at path download.log
➜ qshell-v2.3.4

三、上传文件至腾讯COS

此处省略100字,自行注册腾讯云,创建存储空间。设置空间权限为“公有读私有写”

上传资源可以使用网页端或者下载工具

四、旧资源链接替换/重定向

将资源上传至腾讯COS后,需要将原博客中失效的七牛链接替换为腾讯COS中的链接。不同系统的博客请自行传泽替换方案。替换.md文件中的域名,或从数据库查询替换…

五、图片处理(水印)

七牛空间存储的图片,可以创建图片处理规则,添加水印、缩放等比较方便。然而在腾讯COS存储中没有找到相关配置项。

搜索发现,腾讯的图片处理,统一使用“数据万象”这个单独的应用。在COS创建完存储空间;然后在“数据万象”创建自定义的图片处理规则,并绑定COS存储空间的Bucket即可。且有个优点是可以使用别名代替处理规则,以及可以使用-(中划线) _(下划线) /(斜杠) !(感叹号)四中分割符号。

例如:

  • 图片链接: https://233.com/666.png

  • 如创建的图片规则为: imageMogr2/interlace/0|watermark/1/image/aHR0cDovL3dhdGVybWFyay1iai0xMjUyMTA2MjExLnBpY2JqLm15cWNsb3VkLmNvbS9sdWRpcy0...

  • 别名为: imgRule

以七牛的尿性图片添加完规则变成: https://233.com/666.png?imageMogr2/interlace/0|watermark/1/image/aHR0cDovL3dhdGVybWFyay1iai0xMjUyMTA2MjExLnBpY2JqLm15cWNsb3VkLmNvbS9sdWRpcy0... 👎

腾讯云则很优雅: https://233.com/666.png!imgRule 👏

参考

【译】如丝般顺滑-使用css3实现60帧的动画

原文: Smooth as Butter: Achieving 60 FPS Animations with CSS3

在移动端上实现动画很简单。

如果采取我们的建议的话,在移动端正确的实现动画也会比较容易。

虽然现在很多人在手机上运用CSS3动画,但许多人用的都不够恰当。很多应加以考虑的最佳实践常常被忽略,因为仍然有人不明白这些最佳实践的真正意义。

如今有这么多的设备规范,如果还不有针对性地优化你的代码,糟糕的用户体验将让你死无葬身之地。

记住:虽然市场上始终有一些高端的旗舰机在挑战性能极限,但你面对的仍将是和这些性能怪兽相比只是玩具一样的低端设备。

我们想帮助你正确地驾驭 CSS3。首先先要了解几件事。

理解时间轴

当渲染和处理HTML元素时,浏览器做了什么?这个非常简单的时间轴我们称之为 关键渲染路径

想要达到流畅的动画效果,我们需要关注修改属性会对 composite (合成)阶段造成怎样的影响。而不是去关注前面的其他阶段。

1. Styles

浏览器开始计算样式以应用在元素上——重新计算样式

2. Layout

接下来,浏览器会开始为每个元素生成用于布局的形状和位置信息。在该步骤浏览器会设置的页面属性包括 widthheight,还有margin,以及left/top/right/bottom 等。

3. Paint

在该步骤,浏览器开始用像素渲染填充每个元素,此时用到的属性有 box-shadown, border-radius,color, background-color 等。

4. Composite

这步就是你施展拳脚的地方了,浏览器开始在屏幕上渲染所有的元素。

现代浏览器可以使用transformopacity属性很好的实现四种动画。

  • 位置 —— transform: transformX(n) transformY(n) translateZ(n);
  • 缩放 —— transform: scale(n);
  • 旋转 —— rotate(ndeg);
  • 透明 —— opacity:n;

如何达到每秒60帧

想法有了,可以撸起袖子干活了。

首先从HTML开始,创建一个简单的结构,把类名为app-menu的元素放在一个类名为layout的元素中。

<div class="layout">
    <div class="app-menu"></div>
    <div class="header">
        <div class="menu-icon"></div>
    </div>
</div>

用错误的方法来实现这个效果

1
2
3
4
5
6
7
8
9
.app-menu {
left: -300px;
transition: left 300ms linear;
}

.app-menu-open .app-menu {
left: 0px;
transition: left 300ms linear;
}

看见我们这些我们修改的属性了吗?我们应该避免使用left/top/right/bottom这些属性作为动画。这些属性不能实现流畅的动画,因为他们会让浏览器每次都创建布局,而这会影响他们的子元素。

这样做的结果大概是这样的:

这个动画一点都不流畅。我们使用开发者工具的时间轴来看看发生了什么,结果如下:

这清楚地显示了FPS是不平整的并且表现也很慢。

绿色的条代表FPS,高的条表示动画渲染的帧数达到60FPS。低的条表示小于60帧。所以理想情况下,你想让绿色的长条贯穿整个时间轴。红色的条代表着糟糕的性能,所以另一方面评估程序的方法是消除这些红色的条。

使用 Transform

1
2
3
4
5
6
7
8
9
10
.app-menu {
-webkit-transform: translateX(-100%);
transform: translateX(-100%);
transition: transform 300ms linear;
}
.app-menu-open .app-menu {
-webkit-transform: none;
transform: none;
transition: transform 300ms linear;
}

transform属性影响Composite(合成)属性。这里我们告诉浏览器布局将被渲染并且已经准备好,当动画开始时。所以让渲染动画时卡顿更少。

时间轴的显示如下:

结果变的好点了,FPS更加规律了,因此动画更顺畅了。

使用GPU运行动画

让我们来上升一个等级,准备好让动画变得如丝般顺滑,我们开始使用GPU来渲染动画。

1
2
3
4
5
6
.app-menu {
-webkit-transform: translateX(-100%);
transform: translateX(-100%);
transition: transform 300ms linear;
will-change: transform;
}

虽然一些浏览器仍然需要 translateZ()translate3d() 作为备选方案,但will-change 被广泛支持已经是势不可挡了。它的功能是把元素提升到另一个层中,这样浏览器就不必关心布局渲染或者绘制了。

看见有多顺畅了吗? 时间轴目前是这样的:

动画的FPS更稳定了,渲染也更快了。但是有一帧仍然渲染得很久。在开始处还有一点点瓶颈。

还记得开始时创建的HTML结构吗?让我们来通过JavaScript来控制类名为app-menu的元素。

1
2
3
4
5
6
7
8
9
10
function toggleClassMenu() {
var layout = document.querySelector(".layout");
if (!layout.classList.contains("app-menu-open")) {
layout.classList.add("app-menu-open");
} else {
layout.classList.remove("app-menu-open");
}
}
var oppMenu = document.querySelector(".menu-icon");
oppMenu.addEventListener("click", toggleClassMenu, false);

这儿的问题是给布局中的div元素增加了类名,这样使得浏览器多了一次重新计算样式的步骤,因此影响了渲染表现。

如丝般顺滑的60帧动画解决方案

如果我们在视窗外的区域创建menu元素呢?在脱离主区域的地方这么做,可以确保影响仅限于你想赋予动画的元素。

因此,我们打算使用下面的HTML结构:

1
2
3
4
5
6
7
8
<div class="menu">
<div class="app-menu"></div>
</div>
<div class="layout">
<div class="header">
<div class="menu-icon"></div>
</div>
</div>

现在我们可以以略微不同的方式来控制菜单的状态。我们可以通过使用JavaScript的transitionend方法,在动画结束时移除类名来控制动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function toggleClassMenu() {
myMenu.classList.add("menu--animatable");
if(!myMenu.classList.contains("menu--visible")) {
myMenu.classList.add("menu--visible");
} else {
myMenu.classList.remove('menu--visible');
}
}

function OnTransitionEnd() {
myMenu.classList.remove("menu--animatable");
}

var myMenu = document.querySelector(".menu");
var oppMenu = document.querySelector(".menu-icon");
myMenu.addEventListener("transitionend", OnTransitionEnd, false);
oppMenu.addEventListener("click", toggleClassMenu, false);
myMenu.addEventListener("click", toggleClassMenu, false);

我们来整理一下代码然后检查下结果。

下面是完整的CSS3代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
.menu {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
z-index: 150;
}

.menu--visible {
pointer-events: auto;
}

.app-menu {
background-color: #fff;
color: #fff;
position: relative;
max-width: 400px;
width: 90%;
height: 100%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
-webkit-transform: translateX(-103%);
transform: translateX(-103%);
display: flex;
flex-direction: column;
will-change: transform;
z-index: 160;
pointer-events: auto;
}

.menu--visible .app-menu {
-webkit-transform: none;
transform: none;
}

.menu--animatable .app-menu {
transition: all 130ms ease-in;
}

.menu--visible.menu--animatable .app-menu {
transition: all 330ms ease-out;
}

.menu:after {
content: '';
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
will-change: opacity;
pointer-events: none;
transition: opacity 0.3s cubic-bezier(0, 0, 0.3, 1);
}

.menu--visible.menu:after {
opacity: 1;
pointer-events: auto;
}

那么时间轴表现如何呢?

是不是如丝般顺滑呢?

翻译总结:

目前在给手机页面通过CSS3实现简单动画时,一般很少考虑流畅问题。因为在开发阶段使用的PC机性能都极高,很难主要到这一点。如我目前使用的Mac Pro(i7,16g)。即便使用文中提到的错误示例(第一种方案,通过left控制动画),通过审查工具分析时,其帧数依旧如诗般顺滑...,然而应该当考虑到实际的使用场景是移动端,且就目前而言,确实仍有一大批人在只用较为低端的安卓机。所以采用最佳实践很有必要,并且实际使用的动画会比菜单滑动要复杂。所以是否采取最佳实践方案在实际的设备中运行会出现较大差异。

Mac Photoshop 2018(Adobe全家桶)下载破解

请支持正版软件!!!

适用于Adobe所有软件的安装破解,以 Photoshop 2018 为例。根据电脑语言为中文/英文,安装完的软件自动为中文/英文。

1、下载安装 Adobe creative cloud

下载地址:https://www.adobe.com/creativecloud/desktop-app.html

可以理解为Adobe的下载器,可以使用这个下载器下载所有Adobe产品。

下载完成后,选择登陆/注册Adobe账户,然后你就可以看到如下页面:

而事实上,从官网介绍来看,我们需要的是这样的(Apps页面可下载Adobe的产品):

猜测可能是因为注册的账户是天朝或者所在地为天朝的原因,好多功能被屏蔽了,大天朝还真是一枝独秀,且看我手势 🖕

解决方法:

macOS修改 Adobe creative cloud 的配置文件:false 改为 true

sudo vi /Library/Application Support/Adobe/OOBE/Configs/ServiceConfig.xml

1
<config><panel><name>AppsPanel</name><visible>false</visible></panel><feature><name>SelfServeInstalls</name><enabled>false</enabled></feature></config>
1
<config><panel><name>AppsPanel</name><visible>true</visible></panel><feature><name>SelfServeInstalls</name><enabled>false</enabled></feature></config>

然后重启应用后,Apps Tab已经出现,第一步完成。

2、下载

在Apps页面选择自己需要的软件下载即可。例如Photoshop

3、激活

下载 Adobe zii 激活软件。此神器适用于所有Adobe产品!!!

在所有软件下载完成后,依次进行以下操作:

  1. 断网
  2. 打开/试用一遍所有需要激活的Adobe产品,然后退出
  3. 打开Adobe zii一键激活即可

此时软件已经激活完成,可以正常使用。以下步骤为扩展步骤。

4、阻止联网监测(非必须)

此操作是防止有些Adobe产品自行联网检测激活状态,导致激活时效。这么说吧,用了高枕无忧,不用可能会中奖,自行选择。

下载 Little Snitch 防火墙软件,安装后,将一切以Adobe开头的软件全部禁止联网即可。

5、软件升级

如果进行了第四步,那么在升级时先暂停 Little Snitch,先解除联网限制。

然后运行 Adobe Creative Cloud 进行软件升级,会有升级提示。

最后重复激活的步骤即可。

注:请支持正版软件!!!

instagram 内容抓取

抓取说明

1、需要登录信息,即抓取时需要附带cookie,同时需要user-agent

2、数据获取接口及下载均有频率限制,无间隔的请求(几百个资源)会被限制,在被限制后睡眠一定时间继续。

3、内容抓取分为两个入口

  • 一个是抓取某个用户发布的所有资源
  • 一个是抓取某个tag下的所有资源

两种入口附带的cookie不同,请求的URL不同。

4、抓取步骤:

  1. 电脑端登陆ins,保存 cookiequery_hashuser-agent信息。后续所有请求附带cookieuser-agent
  2. 模拟请求个人主页/tag主页,通过解析HTML页面,得到userId/tag name。同时拿到第一页的数据及下页cursor。
  3. 通过API接口,根据cursor持续获取多页数据。所有数据获取完毕后开始下载。
  4. 返回的数据中,图片资源可以直接下载。视频资源需要再次请求视频地址获取接口获得视频地址,然后再下载。

5、请求数据接口:

user:

https://www.instagram.com/graphql/query/?query_hash=a5164aed103f24b03e7b7747a2d94e3c&variables=%7B%22id%22%3A%22%s%22%2C%22first%22%3A${purePage}%2C%22after%22%3A%22%s%22%7D

tag:

https://www.instagram.com/graphql/query/?query_hash=1780c1b186e2c37de9f7da95ce41bb67&variables=%7B%22tag_name%22%3A%22%s%22%2C%22first%22%3A${purePage}%2C%22after%22%3A%22%s%22%7D

获取视频的地址:

https://www.instagram.com/p/%s/?__a=1

核心代码

/**
 * 获取指定用户的主页
 */
const getHtml = item => {
    let userName = item.name,
        type = item.type
    let url
    if (item.type == 'user') {
        url = `${baseUrl}${userName}/`
        headers.cookie = userCookie
    } else {
        url = `${baseUrl}explore/tags/${userName}/`
        headers.cookie = tagCookie
    }
    let options = {
        method: 'GET',
        url: url,
        headers: headers
    }

return new Promise((resolve, reject) =&gt; {
    request(options, function (error, response, body) {
        if (error) return reject(error);

        const $ = cheerio.load(body)
        let html = $.html()

        // 获取uid/tag name
        userId = item.type == 'user' ? html.match(/"profilePage_([0-9]+)"/)[1] : html.match(/"name":"([a-zA-Z_]+)",/)[1]
        log.info(`${userName} id/name 获取成功 ${userId}`)

        // 获取首页数据
        data = html.match(/&lt;script type="text\/javascript"&gt;window._sharedData = (.*?);&lt;\/script&gt;/)[1]
        data = JSON.parse(data)

        let edges, count, pageInfo, cursor, flag, totalPage

        let firstPageDate

        if (item.type == 'user') {
            firstPageDate = data.entry_data.ProfilePage[0].graphql.user.edge_owner_to_timeline_media
        } else {
            firstPageDate = data.entry_data.TagPage[0].graphql.hashtag.edge_hashtag_to_media
        }

        edges = firstPageDate.edges
        count = firstPageDate.count
        pageInfo = firstPageDate.page_info

        cursor = pageInfo.end_cursor
        flag = pageInfo.has_next_page
        totalPage = Math.ceil(count / purePage)

        // 存储首页信息
        edges.forEach(item =&gt; {
            item.mode = type
            storeMedia(item)
        })

        // 返回分页信息
        return resolve({
            totalPage: totalPage,
            userId: userId,
            cursor: cursor
        })

    });
})

}

/**

  • 获取该用户的所有内容
  • /
    const getAllUrls = (item, totalPage, uid, cursor) => {
    let userName = item.name
    let actions = [async.constant(item, uid, cursor)]
    let limit = totalPage > pageLimit ? pageLimit : totalPage
    for (let i = 0; i < limit; i++) {
      actions.push(fetchData)
    
    }
    log.info(${userName} 数据共 ${totalPage} 页)
    return new Promise((resolve, reject) => {
      async.waterfall(actions, (error, result) =&gt; {
          log.info(`${userName} 的所有帖子数据获取成功,共${media.length}个帖子,视频${videoCount}个,图片${imgCount}个`, )
          fetchPageCount = 0
          //console.log(media)
          return resolve(media)
      })
    
    })

}

/**

  • 请求获取数据

  • /
    const fetchData = (item, uid, offset, next) => {

    let userName = item.name,

      type = item.type
    

    let url

    if (item.type == ‘user’) {

      url = util.format(fetchUserUrl, uid, offset)
      headers.cookie = userCookie
    

    } else {

      url = util.format(fetchTagUrl, uid, offset)
      headers.cookie = tagCookie
    

    }

    let options = {

      method: 'GET',
      url: url,
      headers: headers
    

    };

    request(options, function (error, response, body) {

      if (error) {
          log.error('fetch data error', error)
          log.info('休息1min~')
          return setTimeout(function () {
              return next(null, item, uid, offset)
          }, 1 * 60 * 1000)
      }
    
      let data
      try {
          data = JSON.parse(body)
      } catch (error) {
          log.error('json序列化失败', error)
          return next(null, item, uid, offset) 
      }
      
      if (data.status == 'fail') {
          log.error('返回内容失败', data)
          log.info('休息1min~')
          //return next(data.message)
          return setTimeout(function () {
              return next(null, item, uid, offset)
          }, 1 * 60 * 1000)
      }
    
      let listData
      try {
          if (item.type == 'user') {
              listData = data.data.user.edge_owner_to_timeline_media
          } else {
              listData = data.data.hashtag.edge_hashtag_to_media
          }
      } catch (error) {
          log.error('数据获取失败', error)
          next(error)
      }
    
      let edges = listData.edges
      edges.forEach(item =&gt; {
          item.mode = type
          storeMedia(item)
      })
      let {
          has_next_page,
          end_cursor
      } = listData.page_info
    
      log.info(`page:${++fetchPageCount} ${userName} 数据获取成功,帖子 ${edges.length} 个, has_next_page: ${has_next_page} ,end_cursor: ${end_cursor}`)
    
      if (!has_next_page) {
          return next('所有数据获取完毕,无下页')
      }
      setTimeout(function () {
          return next(null, item, uid, end_cursor)
      }, 2000)
    

    });

}

/**

  • 根据视频的shortcode获取视频的下载地址

  • /
    const fetchVideoUrl = (mode, shortcode) => {
    let url = util.format(getVideoUrl, shortcode)

    if (mode == ‘user’) {

      headers.cookie = userCookie
    

    } else {

      headers.cookie = tagCookie
    

    }
    let options = {

      method: 'GET',
      url: url,
      headers: headers
    

    }
    return new Promise((resolve, reject) => {

      request(options, function (error, response, body) {
          let videoUrl = ''
          if (error) {
              log.error(`获取 ${shortcode} 视频地址失败`, error)
              return resolve(videoUrl)
          }
    
          try {
              let data = JSON.parse(body)
              videoUrl = data.graphql.shortcode_media.video_url
          } catch (error) {
              log.error(`获取 ${shortcode} videoUrl 为空`)
          }
          return resolve(videoUrl)
      })
    

    })

}

/**

  • 根据不同的类型存储数据
  • /
    const storeMedia = async item => {
    let result = {
      id: item.node.id,
      desc: item.node.edge_media_to_caption.edges[0] ? item.node.edge_media_to_caption.edges[0].node.text : ''
    
    }
    if (item.node.is_video) {
      // video
      // 如果有video_url直接获取
      // 如果没有video_url,通过接口获取
      let videoUrl = item.node.video_url
      if (!videoUrl) videoUrl = await fetchVideoUrl(item.mode, item.node.shortcode)
      if (videoUrl) {
          result.type = 'video'
          result.url = videoUrl
          videoCount++
      }
    
    } else {
      // img
      let imgUrl = item.node.display_url
      if (imgUrl) {
          result.type = 'img'
          result.url = imgUrl
          imgCount++
      }
    
    }
    media.push(result)
    }

/**

  • 下载视频/图片

  • /
    const download = (category, media, next) => {

    let isExist = isFileExist(media.id)
    if (isExist) return next(null)

    let filePath
    if (media.type == ‘video’) {

      filePath = `${videoDlPath}/${media.id}.mp4`
    

    } else if (media.type == ‘img’) {

      filePath = `${imgDlPath}/${media.id}.jpg`
    

    } else return next(null)

    let st = new Date()
    request(media.url)

      .on('response', function (res) {
          // create file write stream
          let fws = fs.createWriteStream(filePath);
          // setup piping
          res.pipe(fws);
          // finish
          res.on('end', function (e) {
              let et = new Date()
              let ut = timeUsed((et - st) / 1000)
              log.info(`${videoDl + imgDl} finish download ${category} ${filePath},用时${ut}`)
              saveJsonData(media.type, {
                  id: media.id,
                  category: category,
                  desc: media.desc
              })
              if (media.type == 'video') videoDl++
              else imgDl++
    
              return next(null)
          });
          // error handler
          res.on('error', err =&gt; {
              log.error('download error', err)
              return next(null)
          })
      })
      .on('error', function (err) {
          log.error('request source failed', media.url, err)
          // 大约3分钟可恢复
          log.info('超频啦!休息1分钟~')
          setTimeout(function () {
              return next(null)
          }, 1 * 60 * 1000)
    
      })
    

}

/**

  • 视频是否已下载
  • /
    const isFileExist = id => {
    let videoPath = ${videoDlPath}/${id}.mp4
    let imgPath = ${imgDlPath}/${id}.jpg
    if (fs.existsSync(videoPath)) {
      log.info('video file exist', videoPath)
      return true
    
    } else if (fs.existsSync(imgPath)) {
      log.info('img file exist', imgPath)
      return true
    
    } else return false
    }

/**

  • 视频下载成功后,实时更新json数据。防止程序中途奔溃后视频信息未保存

  • /
    const saveJsonData = (type, data) => {
    try {

      // 读取已有json信息
      let jsonFile = type == 'video' ? videoJsonPath : imgJsonPath
      jsonFile += `/data.json`
    
      let jsonData = []
      if (fs.existsSync(jsonFile)) {
          fileData = fs.readFileSync(jsonFile, {
              encoding: 'utf8'
          })
          if (fileData) {
              jsonData = JSON.parse(fileData)
          }
      }
      // 写入
      jsonData.push(data)
      fs.writeFileSync(jsonFile, JSON.stringify(jsonData));
    

    } catch (error) {

      log.error('写入json文件失败', data)
    

    }

}

const clearData = () => {
media = []
videoCount = 0
imgCount = 0
videoDl = 0
imgDl = 0
}

/**

  • 下载某用户/标签下获取的所有资源
  • /
    const downloadAll = (userName, data) => {
    let dlActions = data.map(item => next => {
      download(userName, item, next)
    
    })
    return new Promise((resolve, reject) => {
      async.series(dlActions, (error, result) =&gt; {
          return resolve(result)
      })
    
    })
    }

/**

  • 用时显示
  • /
    const timeUsed = t => {
    // [1s, 1m)
    if (t < 60) return ${Math.ceil(t)}s
    // [1m, 1h)
    else if (t >= 60 && t < 60 * 60) return ${Math.floor(t/60)}m${Math.floor(t%60)}s
    // [1h, 1d)
    else if (t >= 60 * 60 && t < 60 * 60 * 24) return ${Math.floor(t/(60*60))}h${Math.floor(t%(60*60)/60)}m
    // [1d, ~)
    else return ${ Math.floor(t/(24*60*60)) }d ${ Math.floor( t%(24*60*60)/(60*60) ) }h
    }

/**

  • 某个用户/标签的抓取任务

  • /
    const task = async (item, next) => {
    let userName = item.name

    let {

      totalPage,
      userId,
      cursor
    

    } = await getHtml(item).catch(error => {

      log.error('fetch error', error)
      return next(null)
    

    })

    let data = await getAllUrls(item, totalPage, userId, cursor)

    clearData()

    let st = new Date()
    let download = await downloadAll(userName, data)
    let et = new Date()
    let ut = timeUsed((et - st) / 1000)
    log.info(${userName} 所有下载完成, video ${videoDl} 个,img ${imgDl} 个,共用时 ${ut})
    clearData()
    return next(null)

}

const main = () => {
let actions = target.map(item => next => {
task(item, next)
})
async.series(actions, (error, result) => {
log.info(所有 ${result.length} 个任务完成, error)
process.exit(0)
})
}

main()

完整代码: https://github.com/flute/instagram-crawler

coub.com 内容抓取

抓取说明

1、总共17个分类。

2、数据获取

  • url:https://coub.com/api/v2/timeline/hot/movies/half?per_page=25
  • 说明:movies 为分类。 per_page 为每页返回的数据量[1,25]。首次获取只需传入 page=1 即为第一页的数据。下次请求附带字段 anchor 为上次请求返回的 next 参数即可。

3、每个资源的属性:

  • 唯一标志: id、permalink
  • 资源描述: titile

4、下载

coub.com的音频和视频是分开的,下载的时候需要将音视频分别下载,然后使用FFmpeg合并。
下载及合并使用开源项目 https://github.com/TeeSeal/coub-dl

5、分类数组

["animals-pets", "mashup", "anime", "movies", "gaming", "cartoons", "art", "music", "sports", "science-technology", "celebrity", "nature-travel", "fashion", "dance", "cars", "nsfw"]

核心代码:

/**
 * 获取视频列表,每次请求返回10个视频
 * @param {number} page 请求的页数
 * @param {number} anchor 保证数据的不重复性
 */
function getCoubVideoList(c, page = 1, anchor, next) {
    if (!c) {
        log.error('category empty', c)
        return next(new Error('category empty'), null)
    }
    var options = {
        method: 'GET',
        url: `https://coub.com/api/v2/timeline/hot/${c}/${time}`,
        //url: `https://coub.com/api/v2/timeline/hot/${c}/half`,
        qs: {
            page: page,
            per_page: per_page
        }
    };
    if (anchor) options.qs.anchor = anchor

request(options, function (error, response, body) {
    if (error) {
        next(error, null)
        return
    }
    let data = JSON.parse(body)

    if (data &amp;&amp; data.coubs &amp;&amp; data.coubs.length) {
        log.info(`获取视频列表成功 page ${page}`, data.next, data.coubs.length)
        //videoList.push(data.data)
        videoList = videoList.concat(data.coubs)
        return next(null, c, ++data.page, data.next)
    } else {
        log.info('获取内容为空 page ${page}')
        return next(null, c, ++data.page, data.next)
    }
});

}

/**

  • 获取指定分类的总页数
  • /
    const getTotalPage = (c) => {
    var options = {
      method: 'GET',
      url: `https://coub.com/api/v2/timeline/hot/${c}/${time}`,
      //url: `https://coub.com/api/v2/timeline/hot/${c}/half`,
      qs: {
          page: 1,
          per_page: per_page
      }
    
    };
    return new Promise((resolve, reject) => {
      request(options, function (error, response, body) {
          if (error) return reject(new Error(error))
          let data = JSON.parse(body)
          if (data &amp;&amp; data.total_pages) {
              log.info(`获取${c}总页数成功`, data.total_pages)
              return resolve(data.total_pages)
          } else {
              log.info(`获取${c}总页数失败`)
              return reject(new Error('页数为空'))
          }
      });
    
    })

}

/**

  • 获取多页的视频
  • /
    const getMultiVideo = async c => {
    // 总页数
    let totalPage = await getTotalPage(c)
    // 每页依次队列获取
    let actions = [async.constant(c, startPage, startAnchor)]
    for (let i = 1; i <= totalPage; i++) {
      actions.push(getCoubVideoList)
    
    }
    return new Promise((resolve, reject) => {
      async.waterfall(actions, function (err, result) {
          log.info(`finish crawler ${c} videos`, err, videoList.length)
          if (err) return reject(new Error(err))
          return resolve(videoList)
      })
    
    })
    }

/**

  • 根据视频的permalink下载视频

  • @param {string} id video permalink

  • /
    async function downloadFile(c, video, next) {
    if (!video || !video.permalink) return next(null, ‘’)
    let id = video.permalink

    let filename = ${dlPath}/${id}.mp4

    let isExist = isFileExist(id)
    // 文件已存在
    if (isExist) {

      return next(null, filename)
    

    }

    // 下载操作
    const coub = await Coub.fetch(http://coub.com/view/${id}).catch(error => {

      console.log('fetch error', error)
      return next(null, '')
    

    })
    if (!coub) return next(null, ‘’)
    coub.attachAudio()
    if (fastMode) coub.addOption(‘-c’, ‘copy’)
    coub.addOption(‘-shortest’)
    let ts = new Date()
    coub.write(filename)

      .then(result =&gt; {
          let te = new Date()
          let tu = (te - ts) / 1000
          log.info(`${downloadCount}:finish download ${c} ${id}.mp4`, filename, `用时${tu}s`)
          downloadCount++
          // 视频信息
          let videoInfo = {
              desc: video.title,
              category: c,
              filename: `${id}.mp4`
          }
          // 实时写入json
          saveJsonData(videoInfo)
    
          dlFilesJson.push(videoInfo)
          return next(null, result)
          //return resolve(result)
      })
      .catch(error =&gt; {
          log.error(`download error ${id}.mp4`, error)
          return next(error, '')
          //return reject(error)
      })
    

    }

/**

  • 视频是否已下载

  • /
    const isFileExist = id => {
    let oldPath = path.resolve(__dirname, ./src/video/${id}.mp4);
    let newPath = path.resolve(__dirname, ./downloads/video/${id}.mp4);
    let weeklyPath = path.resolve(__dirname, ./weekly/video/${id}.mp4);
    let monthlyPath = path.resolve(__dirname, ./monthly/video/${id}.mp4);
    let quarterPath = path.resolve(__dirname, ./quarter/video/${id}.mp4);
    let halfPath = path.resolve(__dirname, ./half/video/${id}.mp4);

    if (fs.existsSync(oldPath)) {

      log.info('file exist', oldPath)
      return true
    

    } else if (fs.existsSync(newPath)) {

      log.info('file exist', newPath)
      return true
    

    } else if(fs.existsSync(weeklyPath)){

      log.info('file exist', weeklyPath)
      return true
    

    } else if(fs.existsSync(monthlyPath)){

      log.info('file exist', monthlyPath)
      return true
    

    } else if(fs.existsSync(quarterPath)){

      log.info('file exist', quarterPath)
      return true
    

    } else if(fs.existsSync(halfPath)){

      log.info('file exist', halfPath)
      return true
    

    } else return false
    }

/**

  • 视频下载成功后,实时更新json数据。防止程序中途奔溃后视频信息未保存

  • @param {*} data

  • /
    const saveJsonData = data => {
    try {

      // 读取已有json信息
      let jsonFile = `${jsonPath}/all.json`
    
      let jsonData = []
      if (fs.existsSync(jsonFile)) {
          fileData = fs.readFileSync(jsonFile, {
              encoding: 'utf8'
          })
          if (fileData) {
              jsonData = JSON.parse(fileData)
          }
      }
      // 写入
      jsonData.push(data)
      fs.writeFileSync(jsonFile, JSON.stringify(jsonData));
    

    } catch (error) {

      log.error('写入json文件失败', data)
    

    }

}

/**

  • 使用-C模式,将视频与音频快速合并,速度快,但问题视频较多,视频声音不正常。

  • 使用非-C模式,速度较慢,且由于合并时占用cpu较大,多个视频合并任务同时进行时,电脑基本会卡死

  • 最终采用非-C模式,保证每个视频的音频正常。同时为保证电脑不死机,以队列模式依次处理。唯一缺陷是耗时。

  • /
    async function doDownload(c) {
    let result = await getMultiVideo(c)
    videoList = []
    let data = []
    result.forEach(item => data = data.concat(item))
    log.info(要抓取的 ${c} 类型的视频总数为 ${data.length} 个)

    let actions = data.map(video => next => {

      downloadFile(c, video, next)
    

    })

    return new Promise((resolve, reject) => {

      let st = new Date()
      async.series(actions, function (err, result) {
          let et = new Date()
          let ut = timeUsed((et - st) / 1000)
          log.info(`finish download ${c} video, 耗时 ${ut}`, err, result.length)
    
          if (err) return reject(new Error(err))
          // 每个分类的json
          fs.writeFileSync(`${jsonPath}/${c}.json`, JSON.stringify(dlFilesJson));
          dlFilesJson = []
          downloadCount = 1
          return resolve(result)
      })
    

    })

}

async function main() {

let animals_pets = await doDownload('animals-pets')
let mashup = await doDownload('mashup')
let anime = await doDownload('anime')
let movies = await doDownload('movies')
let gaming = await doDownload('gaming')
let cartoons = await doDownload('cartoons')
let art = await doDownload('art')
let music = await doDownload('music')
let news  = await doDownload('news')
let sports = await doDownload('sports')
let science_technology = await doDownload('science-technology')
let celebrity = await doDownload('celebrity')
let nature_travel = await doDownload('nature-travel')
let fashion = await doDownload('fashion')
let dance = await doDownload('dance')
let cars = await doDownload('cars')
let nsfw = await doDownload('nsfw')

return true

}

/**

  • 用时显示
  • /
    const timeUsed = t => {
    // [1s, 1m)
    if (t < 60) return ${Math.round(t)}s
    // [1m, 1h)
    else if (t >= 60 && t < 60 * 60) return ${Math.floor(t/60)}m${Math.floor(t%60)}s
    // [1h, 1d)
    else if (t >= 60 * 60 && t < 60 * 60 * 24) return ${Math.floor(t/(60*60))}h${Math.floor(t%(60*60)/60)}m
    // [1d, ~)
    else return ${ Math.floor(t/(24*60*60)) }d ${ Math.floor( t%(24*60*60)/(60*60) ) }h
    }

main()
.then(result => {
let endTime = new Date()
let usedTime = timeUsed((endTime - startTime) / 1000)
log.info(all downloads finish,${result} 个视频,共耗时 ${usedTime}, )
})
.catch(error => {
log.error(‘download error’, error)
})
.then(() => {
process.exit(0)
})

process.on(‘uncaughtException’, err => {
log.info(err)
log.info(JSON.stringify(dlFilesJson))
})

完整代码: https://github.com/flute/coub-crawler

9GAG.com 内容抓取

抓取说明

1、总共52个分类。

2、数据获取

  • url:https://9gag.com/v1/group-posts/group/cute/type/hot?c=10
  • 说明:cute 为分类。首次获取只需传入 c=10 即为前十条数据。下次请求附带上次请求返回的 nextCursor 参数即可。每次请求返回10条数据。

3、每个资源的属性:

  • 唯一标志: id
  • 资源描述: titile

4、资源分三种类型,根据images属性下的字段区分

  1. image  属性:image460    image700  
  2. gif  属性:image460    image460sv  image460svwm    image700 说明:image460sv image460svwm 两个属性下的 hasAudio 字段为0,及为无声,即为GIF  
  3. video  属性:image460    image460sv  image460svwm   image700 说明:image460sv image460svwm 两个属性下的 hasAudio 字段为1,及为有声,即为video  

5、内容字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
image460: {
height: 258
url: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460s.jpg"
webpUrl: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460swp.webp"
width: 460
}

image460sv: {
duration: 32
h265Url: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460svh265.mp4"
hasAudio: 1
height: 258
url: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460sv.mp4"
vp9Url: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460svvp9.webm"
width: 460
}

image460svwm: {
duration: 32
hasAudio: 1
height: 258
url: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460svwm.webm"
width: 460
}

image700: {
height: 258
url: "https://img-9gag-fun.9cache.com/photo/aq73Yrj_460s.jpg"
width: 460
}

6、分类数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
[
'funny',
'cute',
'anime-manga',
'ask9gag',
'awesome',
'basketball',
'car',
'comic',
'cosplay',
'country',
'classicalartmemes',
'imadedis',
'drawing',
'animefanart',
'food',
'football',
'fortnite',
'gaming',
'gif',
'girl',
'girly',
'guy',
'history',
'horror',
'home',
'kpop',
'leagueoflegends',
'lego',
'movie-tv',
'music',
'overwatch',
'pcmr',
'photography',
'pokemon',
'politics',
'relationship',
'pubg',
'roastme',
'savage',
'starwars',
'satisfying',
'school',
'science',
'superhero',
'surrealmemes',
'sport',
'travel',
'timely',
'video',
'warhammer',
'wallpaper',
'wtf'
]

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
/**
* 获取内容
*/
const get9gagList = (category, offset, next) = & gt; {

var options = {
method: 'GET',
url: `https://9gag.com/v1/group-posts/group/${category}/type/${type}?`
};

if (offset == '') {
options.url += 'c=10'
} else if (offset == -1) {
return next('complete')
} else {
options.url += offset
}

request(options, function (error, response, body) {
if (error) {
next(error, null)
return
}
let data = JSON.parse(body)

if (data & amp; & amp; data.data & amp; & amp; data.data.posts & amp; & amp; data.data.posts.length) {
log.info(`获取 ${category} 视频列表成功 offset ${offset? offset: 'c=10'}`, data.data.posts.length)
//videoList.push(data.data)
videoList = videoList.concat(data.data.posts)
return next(null, category, data.data.nextCursor)
} else {
log.info(`获取 ${category} 内容为空 offset ${offset},所有数据获取完毕 。`)
return next(null, category, -1)
}
});

}

/**
* 批量获取内容列表
*/
const getMultiList = async category = & gt; {
// 每页依次队列获取
let actions = [async.constant(category, '')]
for (let i = 1; i & lt; = pageCount; i++) {
actions.push(get9gagList)
}
return new Promise((resolve, reject) = & gt; {
async.waterfall(actions, function (err, result) {
log.info(`finish crawler ${category} videos`, err, videoList.length)
//if (err) return reject(new Error(err))
if (err) log.info(err)
return resolve(videoList)
})
})
}

/**
* 下载视频/图片
*/
const download = (category, media, next) = & gt; {
//return new Promise((resolve, reject) =&gt; {
let isExist = isFileExist(media.id)
if (isExist) return next(null)

let filePath
if (media.type == 'video') {
filePath = `${videoDlPath}/${media.id}.mp4`
} else if (media.type == 'img') {
filePath = `${imgDlPath}/${media.id}.jpg`
} else return next(null)

request(media.url)
.on('response', function (res) {
// create file write stream
var fws = fs.createWriteStream(filePath);
// setup piping
res.pipe(fws);
// finish
res.on('end', function (e) {
log.info(`finish download ${category} ${filePath}`)
saveJsonData(media.type, {
id: media.id,
category: category,
desc: media.desc
})
if (media.type == 'video') videoAmount++
else imgAmount++

//return resolve(filePath)
return next(null)
});
// error handler
res.on('error', err = & gt; {
log.error('download error', err)
//return reject(err)
return next(null)
})
});
//})
}

/**
* 视频是否已下载
*/
const isFileExist = id = & gt; {
let videoPath = `${videoDlPath}/${id}.mp4`
let imgPath = `${imgDlPath}/${id}.jpg`
if (fs.existsSync(videoPath)) {
log.info('video file exist', videoPath)
return true
} else if (fs.existsSync(imgPath)) {
log.info('img file exist', imgPath)
return true
} else return false
}

/**
* 视频下载成功后,实时更新json数据。防止程序中途奔溃后视频信息未保存
*/
const saveJsonData = (type, data) = & gt; {
try {
// 读取已有json信息
let jsonFile = type == 'video' ? videoJsonPath : imgJsonPath
jsonFile += `/data.json`

let jsonData = []
if (fs.existsSync(jsonFile)) {
fileData = fs.readFileSync(jsonFile, {
encoding: 'utf8'
})
if (fileData) {
jsonData = JSON.parse(fileData)
}
}
// 写入
jsonData.push(data)
fs.writeFileSync(jsonFile, JSON.stringify(jsonData));

} catch (error) {
log.error('写入json文件失败', data)
}

}

/**
* 将无声MP4转为gif图
*/
const convertVideoToGift = () = & gt; {
let videoPath = './233.mp4'
var command = ffmpeg(videoPath)
.format('gif');
command.save('./233.gif');
}

/**
* 内容筛选,只下载有声视频
*/
const mediaFilter = data = & gt; {
let results = [],
videos = [],
imgs = []
for (let i = 0; i & lt; data.length; i++) {
let video = data[i]
if (video.images.image460sv & amp; & amp; video.images.image460sv.hasAudio & amp; & amp; video.images.image460sv.url) {
// 有声视频
videos.push({
id: video.id,
type: 'video',
url: video.images.image460sv.url,
desc: video.title
})
} else if (!video.images.image460sv & amp; & amp; video.images.image460.url) {
// 图片
imgs.push({
id: video.id,
type: 'img',
url: video.images.image460.url,
desc: video.title
})
}
}
return {
results: results.concat(videos, imgs),
video: videos.length,
img: imgs.length
}
}

/**
* 每个分类的抓取任务
*/
const task = async (category, next) = & gt; {
let videoLists = await getMultiList(category)
videoList = []
log.info('数据获取成功', videoLists.length)
let {
results: videos,
video,
img
} = mediaFilter(videoLists)
log.info(`${videoLists.length} 个内容,有声视频共 ${video} 个,图片共 ${img} 个`)

let dlActions = videos.map(video = & gt; next = & gt; {
return download(category, video, next)
})

async.series(dlActions, (err, result) = & gt; {
if (err) {
log.error(`finish【${category}】all download error`, error)
return next(error)
}
log.info(`finish【${category}】all downloads success`, result.filter(item = & gt; item).length)
return next(null)
})
}

const main = () = & gt; {

let actions = category.map(item = & gt; next = & gt; {
return task(item, next)
})

return new Promise((resolve, reject) = & gt; {
async.series(actions, function (err, result) {
if (err) return reject(new Error(err))
return resolve(result)
})
})
}

main()
.then(result = & gt; {
log.info(`awsome! all ${result.length} tasks finish success! video: ${videoAmount} 个, img: ${imgAmount} 个`, )
})
.catch(error = & gt; {
log.info(`all tasks finish error! video: ${videoAmount}, img: ${imgAmount}`, error)
})
.then(() = & gt; {
process.exit(0)
})

完整代码: https://github.com/flute/9gag-crawler

干锅土豆片+尖椒肉丝

虽然国家一直在推行简化各种手续的办理流程,但是距离像网上购物一样便捷的愿望,真的是还差两个西天取经的路程🙄。不吐槽了,开始主题。

忙里偷闲的一天,办完手续中午在家自己整点吃的。冰箱打开只有土豆、洋葱、辣椒、肉丝。那就整两个菜吧,如题。其实应该叫家常土豆片?不过放在干锅里就叫干锅土豆片了…。别问我为什么这么喜欢土豆,因为我种过将近十年土豆😂。非常简单实用的两个小菜。

材料

  • 土豆
  • 洋葱
  • 辣椒
  • 葱、姜、蒜、干辣椒
  • 火锅底料
  • 生抽、老抽、盐、胡椒粉、鸡精

开搞

一、干锅土豆片

1、准备食材:
食材准备,土豆切片洗净,洋葱切片,辣椒切成丝或者快都行。葱姜蒜切好,少许火锅底料/豆瓣酱。

2、炒土豆:

  1. 开火热锅,锅热后倒少许油。
  2. 稍许油热后倒入少许火锅底料、干辣椒、葱姜蒜,煸香后倒入土豆翻炒,中途可加入少许食盐、胡椒粉。
  3. 大约翻炒大约2分钟左右,土豆片已经半熟。倒入洋葱继续翻炒,加入老抽上色。油干的时候边炒边加入少许清水,分批次少量加。这样小炒3分钟左右土豆基本熟了。
  4. 加入辣椒,倒入酱油提味,再翻炒一分钟,然后加入味精提味,加入少许盐。喜欢酸味的可以加入少许醋。就可以出锅了。

就是这么简单。

二、尖椒肉丝

1、准备食材:
整块猪肉的需要自己切成肉丝,从冰箱拿出来稍微解冻一点后更好切。或者直接买肉时让人家机器切成肉丝最省事。尖椒切成条状,与肉丝类似。肉用水冲洗后,可以用加料酒、胡椒粉、盐稍微腌一会会更有味。葱姜蒜必备。

2、开炒

  1. 类似的,热锅到油,热油倒入葱姜蒜。
  2. 然后倒入肉丝翻炒,炒至肉丝变色即可,中途加入少许盐、胡椒粉等,倒入老抽上色。
  3. 然后倒入尖椒翻炒,生抽提味,翻炒1~2分钟即可,然后味精提味出锅即可。

注意肉丝不能炒的时间长,肉容易变干变老。

两个菜都比较简单,基本上20分钟两个菜就搞定。一个人的话蒸米饭可以用煮蛋器蒸一碗,刚好够。

最后上图

有的步骤忘拍了,自行脑补。

NEM(新经币)公链对接

首先祝Chrome十周年生快,升级后的69耳目一新,继续加油。

前言

通常,当某个交易所要上新币的时候,都会提前下发通知。如果仔细点就会发现,如果是ERC20 Token的话,交易所基本是“秒上”,而对于其他非以太坊公链的Token,交易所一般会提前半月至一月发上币通知。原因很简单,对于ERC20 Token,由于都是运行在以太坊公链上,所以有着共通的运行机制,对于不同的Token,通过调用geth节点的API只需传入不同的合约地址,就能执行不同Token的转账、查余额等等一系列合约方法。也就是说代码可以完全复用,交易所上币只是添加了一个新币的合约地址,就能和其他ERC20 Token共用一套代码。

而对于非以太坊公链的Token,它们运行在其他完全不同的公链上。由于公链之间的开发语言、账户设计、Token设计、Token转账流程、共识机制等等都存在着天差地别,所以对这些公链上的Token进行操作,流程是不同于ERC20 Token的,也就是说代码不能与以太坊的复用。需要重新写一套适配该公链的程序,来完成对该公链上Token的发行、转账、查询等一些列操作。这就是为什么交易所在上非ERC20 Token时耗时较长的原因。

本文记录对于NEM(新经币)的对接过程。

新经币介绍

维基百科介绍:

新经币(New Economy Movement,缩写 NEM),是一种点对点虚拟货币。2015年初发布,其源代码由Java编写并100%属于原创。[1]NEM 广泛发布于人群中[2],其块链采用了全新发明的基于重要性证明POI的同步解决方案。NEM特征也包括:完整的点对点安全系统加密信息系统和基于Eigentrust++算法的声望系统。[3]

新经币NEM使用Java开发,且使用独创的POI(重要性证明机制),并且融合多重签名技术。单从这三点来看,NEM在技术和创新上,在各公链中属于上等马。

NEM新经币的原生Token为XEM。市值在60~100亿之间浮动,排名在10~20名之间。

新经币允许用户在其链上发型资产“mosaic”(翻译过来就是马赛克🌚),对标以太坊上发行的ERC20 Token。区别在于,发行的mosaic并不是以合约形式进行的,所以功能非常简单,不能像以太坊智能合约一样实现多样化的功能。它只具备Token的基础属性,即名称、发行量、精度。以及一些附加属性如发行量是否可修改、是否可转账、描述等。

mosaic代币在链上的唯一标识为mosaic id,对标以太坊合约的合约地址。mosaic id由两部分组成:namespace + mosaic name。所以在创建一个新的mosaic前,需要先创建一个namespace,然后在该namespace下创建mosaic name。以pundix这个Token来说,它在NEM链上发行的mosaic的id为 pundix:npxs , 其中 pundix是它的namespace,npxs是其mosaic name。创建一个namespace需要花费100XEM,且全网唯一,不可重复。创建完namespace后,就可以在该namespace下创建mosaic,创建一个mosaic花费10XEM。所以在NEM链上发行一个mosaic代币共需要110XEM+交易手续费(约0.3XEM)。且namespace不得与已存在的重复,该namespace下创建的mosaic的名称也不得出现重复。

NEM的账户地址分为测试网可主网。测试网地址以T开头,主网地址以N开头。(创建的时候可以挑NB开头的地址👍🏿)

开发文档

官网: https://nem.io
GitHub: https://github.com/NemProject
文档: http://docs.nem.io/en
NIS节点API文档: https://nemproject.github.io/

NEM官方提供的API SDK比较全面,相当良心:

NEM节点部署

如果想在本地启动节点并加入到NEM网络当中,过程非常简单。

http://bob.nem.ninja/下载nis最新包之后,解压。nis目录下的config.properties是一些节点信息配置。可以根据需要修改。然后直接运行nix.runNis.sh即可启动节点。

 ✘ ludis@Mac  ~/Downloads/package  ./nix.runNis.sh
2018-09-06 06:26:38.598 信息 NEM logging has been bootstrapped! (org.nem.deploy.g a)
2018-09-06 06:26:38.617 信息 Acquiring exclusive lock to lock file: /Users/ludis/nem/nis.lock (org.nem.deploy.CommonStarter tryAcquireLock)
2018-09-06 06:26:38.623 警告 no certificate found for (file:/Users/ludis/Downloads/package/nis/nem-deploy-0.6.95-BETA.jar <no signer certificates>) (org.nem.core.metadata.CodeSourceFacade <init>)
2018-09-06 06:26:38.626 信息 Analyzing meta data in <nem-deploy-0.6.95-BETA.jar> (org.nem.core.metadata.JarFacade <init>)
2018-09-06 06:26:38.636 信息 Meta data title <NEM Deploy>, version <0.6.95-BETA> (org.nem.core.metadata.JarFacade <init>)
2018-09-06 06:26:38.639 信息 Starting embedded Jetty Server. (org.nem.deploy.CommonStarter main)
2018-09-06 06:26:39.148 信息 Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@56ef9176: startup date [Thu Sep 06 14:26:39 CST 2018]; root of context hierarchy (org.springframework.context.annotation.AnnotationConfigApplicationContext prepareRefresh)
2018-09-06 06:26:40.751 信息 Loaded JDBC driver: org.h2.Driver (org.springframework.jdbc.datasource.DriverManagerDataSource setDriverClassName)
2018-09-06 06:26:41.394 信息 Database: jdbc:h2:/Users/ludis/nem/nis/data/nis5_mainnet (H2 1.3) (org.flywaydb.core.internal.dbsupport.DbSupportFactory info)
2018-09-06 06:26:41.553 信息 Current version of schema "PUBLIC": 1.0.7 (org.flywaydb.core.internal.command.DbMigrate info)
2018-09-06 06:26:41.555 信息 Schema "PUBLIC" is up to date. No migration necessary. (org.flywaydb.core.internal.command.DbMigrate info)
2018-09-06 06:26:41.834 INFO HCANN000001: Hibernate Commons Annotations {4.0.4.Final} (org.hibernate.annotations.common.reflection.java.JavaReflectionManager <clinit>)
2018-09-06 06:26:41.848 INFO HHH000412: Hibernate Core {4.3.0.Final} (org.hibernate.Version logVersion)
2018-09-06 06:26:41.853 INFO HHH000206: hibernate.properties not found (org.hibernate.cfg.Environment <clinit>)
2018-09-06 06:26:41.860 INFO HHH000021: Bytecode provider name : javassist (org.hibernate.cfg.Environment buildBytecodeProvider)
2018-09-06 06:26:42.398 INFO HHH000400: Using dialect: org.hibernate.dialect.H2Dialect (org.hibernate.dialect.Dialect <init>)
2018-09-06 06:26:42.782 INFO HHH000399: Using default transaction strategy (direct JDBC transactions) (org.hibernate.engine.transaction.internal.TransactionFactoryInitiator initiateService)
2018-09-06 06:26:42.791 INFO HHH000397: Using ASTQueryTranslatorFactory (org.hibernate.hql.internal.ast.ASTQueryTranslatorFactory <init>)
2018-09-06 06:26:42.898 INFO HV000001: Hibernate Validator 5.0.2.Final (org.hibernate.validator.internal.util.Version <clinit>)
2018-09-06 06:26:45.175 信息 Using DataSource [org.springframework.jdbc.datasource.DriverManagerDataSource@5792c08c] of Hibernate SessionFactory for HibernateTransactionManager (org.springframework.orm.hibernate4.HibernateTransactionManager afterPropertiesSet)
2018-09-06 06:26:45.239 警告 context ================== current: 108627620 (org.nem.nis.NisMain init)

……

……

2018-09-06 14:26:51.640:INFO:oejsh.ContextHandler:main: Started o.e.j.s.ServletContextHandler@38c460e8{/,null,AVAILABLE}
2018-09-06 14:26:51.640:INFO:oejs.ServerConnector:main: Started ServerConnector@7a814310{HTTP/1.1}{0.0.0.0:7890}
2018-09-06 14:26:51.644:INFO:oejs.Server:main: Started @14689ms
2018-09-06 06:26:51.644 信息 NEM Deploy is ready to serve. URL is “http://192.168.128.10:7890/". (org.nem.deploy.CommonStarter a)
2018-09-06 06:26:51.651 信息 loadBlocks (from height 2802 to height 2901) needed 40ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:51.717 信息 loadBlocks (from height 2902 to height 3001) needed 28ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:51.762 信息 loadBlocks (from height 3002 to height 3101) needed 27ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:51.811 信息 loadBlocks (from height 3102 to height 3201) needed 25ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:51.855 信息 loadBlocks (from height 3202 to height 3301) needed 25ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:51.911 信息 loadBlocks (from height 3302 to height 3401) needed 28ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:51.958 信息 loadBlocks (from height 3402 to height 3501) needed 30ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:52.004 信息 loadBlocks (from height 3502 to height 3601) needed 27ms (org.nem.nis.dao.BlockDaoImpl d)
2018-09-06 06:26:52.063 信息 loadBlocks (from height 3602 to height 3701) needed 24ms (org.nem.nis.dao.BlockDaoImpl d)
……

这样本地的NIS节点就启动且连接到NEM网络了。可以对照文档进行各种功能测试。

区块链浏览器

主网
http://explorer.nemchina.com
http://chain.nem.ninja

测试网
http://bob.nem.ninja:8765

nem faucet

测试时一些Token是必须的,但nem水龙头是真的不好找啊,能搜到的一些基本都停用了。。且用且珍惜。

https://xarleecm.com/en/nemfaucet

提示:使用这个水龙头申请Token时,需要先用该页面的插件挖矿几分钟后,才可以申请🤔,有点意思哈。没有免费的午餐么,大夏天的,我这air风扇转的那叫一个欢乐。风扇转完点提交,一般几个小时内就会把Token发到你的账户,可以去浏览器查询。

每小时最多申请100XEM,我申请了10个却意外地得到了500个,果然是个看脸的时代😏。因为创建mosaic需要110+的NEM,所以最好申请多点。

转账

这里使用node sdk,直接将XEM及mosaic转账及余额查询封装起来。需要的可以直接拿去用,转账的具体流程也写的比较清楚,可以参考注释和文档理解。

/**

  • nem及mosaic发送交易封装
  • /

const nem = require(“nem-sdk”).default;
const config = require(‘./nem_config’)
const logger = require(‘./logger’);

/**

  • 创建 endpoint 对象(节点信息)
  • host string An NIS uri
  • port string An NIS port
  • /
    const endpoint = nem.model.objects.create(“endpoint”)(config.endpointHost, config.endpointPort)
    logger.info(‘endpoint’, endpoint)

/**

  • 创建 common 对象 (账户信息)
  • password string A password
  • privateKey string A private key
  • /
    const common = nem.model.objects.create(“common”)(config.password, config.privatekey)
    // console.log(‘common’, common)

init()

/**

  • nem及mosaic转账入口

  • /
    const doNemTransaction = (req, callback) => {

    let {

      name,
      address: recipient,
      value: amount
    

    } = req.body

    let option = {

      name: name,
      recipient: recipient,
      amount: amount
    

    }
    logger.info(‘option’, option)

    // mosaic Token转账时,transferTransaction amount代表:要执行后续定义的mosaicAttachment的次数!!!
    // 而非代表nem的金额,此处与eth Token转账完全不同。为0时转账会成功,但mosaic不会到账~~
    if (name == ‘NEM’) {

      amount /= Math.pow(10, 6)
      option.amount = amount
    

    } else {

      amount = 1
    

    }

    /**

    • 创建 transferTransaction 对象(交易信息)
    • recipient string A recipient address
    • amount number An amount
    • message string A message to join
    • /
      option.transferTransaction = nem.model.objects.create(“transferTransaction”)(recipient, amount, config.message);
      logger.info(‘transferTransaction:’, option.transferTransaction)

    if (name == ‘NEM’) transferNem(option, callback)
    else transferMosaic(option, callback)
    }

/**

  • 转账nem

  • /
    const transferNem = (option, callback) => {
    /**

    • 签名/打包交易信息
    • common object A common object
    • tx object A transferTransaction object
    • network number A network id
    • /
      const transactionEntity = nem.model.transactions.prepare(“transferTransaction”)(common, option.transferTransaction, config.networkId)
      logger.info(‘transactionEntity:’, transactionEntity)

    /**

    • 计算nem交易手续费
    • 0.05 XEM per 10,000 XEM transferred, capped at 1.25 XEM
    • Example: 0.20 XEM fee for a 45,000 XEM transfer, 1.25 XEM fee for a 500,000 XEM transfer.
    • /
      if (option.amount > 500000) return callback(new Error(‘转账nem不得超过500000’), null)
      let nemFee = Math.floor(option.amount / 10000) * 0.05
      nemFee = nemFee < 0.05 ? 0.05 : nemFee
      nemFee = nemFee > 1.25 ? 1.25 : nemFee

    /**

    • 计算message fee
    • message fee. 0.05 XEM per commenced 32 bytes
    • If the message is empty, the fee will be 0
    • @param {object} message - An message object
    • @param {boolean} isHW - True if hardware wallet, false otherwise
    • @return {number} - The message fee
    • /
      let messageFee = nem.model.fees.calculateMessage(transactionEntity.message, false)

    let totalFee = nemFee + messageFee
    totalFee = totalFee * Math.pow(10, 6)
    logger.info(‘nemFee:’, nemFee, ‘messageFee’, messageFee, ‘totalFee’, totalFee)
    transactionEntity.fee = totalFee

    /**

    • 发送交易(广播交易)
    • common object A common object
    • entity object A prepared transaction object
    • endpoint object An endpoint object
    • /

    // Serialize transfer transaction and announce
    nem.model.transactions.send(common, transactionEntity, endpoint)

      .then(function (res) {
          logger.info("交易详情:", res)
          //callback(null, res)
          if (res &amp;&amp; res.message == 'SUCCESS') callback(null, res)
          else callback(new Error(res.message), null)
      })
      .catch(error =&gt; {
          logger.error('交易失败:', error)
          callback(error, null)
      });
    

    }

/**

  • 转账mosaic Token

  • /
    const transferMosaic = async (option, callback) => {

    const mosaicData = config.mosaicDefinitions[option.name]
    if (!mosaicData) return callback(new Error(‘不支持该币种’), null)
    const {

      namespaceId,
      name: mosaicName
    

    } = mosaicData.mosaic.id
    // mosaic需转换单位
    let divisibility
    mosaicData.mosaic.properties.forEach(item => {

      if (item.name == 'divisibility') divisibility = item.value
    

    })
    if (!divisibility) return callback(new Error(‘divisibility not found’), null)
    //blockchain.server 发送转账请求时已经将进制转换,不需要进行二次转换
    //option.amount *= Math.pow(10, divisibility)

    /**

    • 创建 mosaicDefinitionMetaDataPair 对象
    • Create variable to store our mosaic definitions, needed to calculate fees properly (already contains xem definition)
    • doc: https://nemproject.github.io/#mosaicDefinitionMetaDataPair
    • /
      const mosaicDefinitionMetaDataPair = nem.model.objects.get(“mosaicDefinitionMetaDataPair”);
      logger.info(‘mosaicDefinitionMetaDataPair’, mosaicDefinitionMetaDataPair)

    /**

    • 创建 mosaic 对象(mosaic是nem上的Token,类比Erc20 Token)
    • namespaceId string A namespace name
    • mosaicName string A mosaic name
    • quantity long number A quantity in micro-units(根据divisibility转换为micro-units,npxsxem: 1000000 = 1 )
    • doc: https://nemproject.github.io/#retrieving-mosaic-definitions
    • /
      var mosaicAttachment = nem.model.objects.create(“mosaicAttachment”)(namespaceId, mosaicName, option.amount);
      logger.info(‘mosaicAttachment’, mosaicAttachment)

    // Push attachment into transaction mosaics
    option.transferTransaction.mosaics.push(mosaicAttachment);
    logger.info(‘transferTransaction’, option.transferTransaction)

// 可通过接口实时获取mosaic属性 nem.com.requests.namespace.mosaicDefinitions(endpoint, mosaicAttachment.mosaicId.namespaceId)
// 当前mosaic较少,事先通过接口获取后写在配置文件
mosaicDefinitionMetaDataPair[mosaicData.fullMosaicName] = {};
mosaicDefinitionMetaDataPair[mosaicData.fullMosaicName].mosaicDefinition = mosaicData.mosaic;

// nem mosaic 转账bug: supply为空,导致calculateMosaics返回NaN
// https://github.com/QuantumMechanics/NEM-sdk/issues/36
// https://qiita.com/xiaca/items/9fa40061cd4977b13147
let res = await nem.com.requests.mosaic.supply(endpoint, mosaicData.fullMosaicName)
mosaicDefinitionMetaDataPair[mosaicData.fullMosaicName].supply = res.supply;

/**
 * 签名/打包交易信息
 * common    object    A common object
 * tx    object    A transferTransaction object
 * mosaicDefinitionMetaDataPair    object    A mosaicDefinitionMetaDataPair object
 * network    number    A network id
 */
let transactionEntity = nem.model.transactions.prepare("mosaicTransferTransaction")(common, option.transferTransaction, mosaicDefinitionMetaDataPair, config.networkId);
logger.info('transactionEntity', transactionEntity)

/**
 * 计算mosaic交易手续费
 * https://nemproject.github.io/#transaction-fees
 * @param {number} multiplier - A quantity multiplier
 * @param {object} mosaics - A mosaicDefinitionMetaDataPair object
 * @param {array} attachedMosaics - An array of mosaics to send
 * @return {number} - The fee amount for the mosaics in the transaction
 */
let mosaicsFee = nem.model.fees.calculateMosaics(1000000, mosaicDefinitionMetaDataPair, option.transferTransaction.mosaics)

/**
 * 计算message fee
 * message fee. 0.05 XEM per commenced 32 bytes
 * If the message is empty, the fee will be 0
 * @param {object} message - An message object
 * @param {boolean} isHW - True if hardware wallet, false otherwise
 * @return {number} - The message fee
 */
let messageFee = nem.model.fees.calculateMessage(transactionEntity.message, false)

let totalFee = mosaicsFee + messageFee
totalFee = totalFee * Math.pow(10, 6)
logger.info('mosaicsFee:', mosaicsFee, 'messageFee', messageFee, 'totalFee', totalFee)
transactionEntity.fee = totalFee
// transactionEntity.fee = 1000000
logger.info('transactionEntity', transactionEntity)

// Serialize transfer transaction and announce
nem.model.transactions.send(common, transactionEntity, endpoint)
    .then(function (res) {
        logger.info("交易详情:", res)
        //callback(null, res.transactionHash.data)
        if (res &amp;&amp; res.message == 'SUCCESS') callback(null, res.transactionHash.data)
        else callback(new Error(res.message), null)
    })
    .catch(error =&gt; {
        logger.error('交易失败:', error)
        callback(error, null)
    });

/*
// 通过接口实时获取mosaic信息,有levy属性的需要根据levy增加nem amount
nem.com.requests.namespace.mosaicDefinitions(endpoint, mosaicAttachment.mosaicId.namespaceId).then(function(res) {

    // Look for the mosaic definition(s) we want in the request response
    var neededDefinition = nem.utils.helpers.searchMosaicDefinitionArray(res.data, ["nem"]);
    console.log('neededDefinition', neededDefinition)
    // Get full name of mosaic to use as object key
    var fullMosaicName  = nem.utils.format.mosaicIdToName(mosaicAttachment.mosaicId);

    // Check if the mosaic was found
    if(undefined === neededDefinition[fullMosaicName]) return console.error("Mosaic not found !");

    // Set eur mosaic definition into mosaicDefinitionMetaDataPair
    mosaicDefinitionMetaDataPair[fullMosaicName] = {};
    mosaicDefinitionMetaDataPair[fullMosaicName].mosaicDefinition = neededDefinition[fullMosaicName];

    // Prepare the transfer transaction object
    var transactionEntity = nem.model.transactions.prepare("mosaicTransferTransaction")(common, option.transferTransaction, mosaicDefinitionMetaDataPair, config.networkId);
    transactionEntity.fee = 1000000
    // Serialize transfer transaction and announce
    nem.model.transactions.send(common, transactionEntity, endpoint)
    .then(function (res) {
        console.log("交易详情:", res)
        callback(null, res)
    })
    .catch(error =&gt; {
        console.log('交易失败:', error)
        callback(error, null)
    });
},
function(err) {
    console.error(err);
});
 */

}

/**

  • 获取账户余额
  • /
    const getNemBalance = (req, callback) => {
    let {
      coin: name,
      address
    
    } = req.query
    address = address ? address : config.adminAddress
    let namespaceId, mosaicName
    let balance = 0
    if (name == ‘NEM’) {
      namespaceId = 'nem'
      mosaicName = 'xem'
    
    } else {
      if (!config.mosaicDefinitions[name]) return callback(new Error('不支持该mosaic'), null)
      let mosaicData = config.mosaicDefinitions[name].mosaic.id
      namespaceId = mosaicData.namespaceId
      mosaicName = mosaicData.name
    
    }
    // http://192.3.61.243:7890/account/get?address=TCKUVV6PETWYVUBGTWRCLUROJOJVPY4JZXNRSDKT
    // http://192.3.61.243:7890/account/mosaic/owned?address=TCKUVV6PETWYVUBGTWRCLUROJOJVPY4JZXNRSDKT
    nem.com.requests.account.mosaics.owned(endpoint, address).then(result => {
      result.data.forEach(mosaic =&gt; {
          if (mosaic.mosaicId.namespaceId == namespaceId &amp;&amp; mosaic.mosaicId.name == mosaicName) balance = mosaic.quantity
      })
      logger.info('get mosaics owned', JSON.stringify(result))
      callback(null, String(balance))
    
    }).catch(error => {
      logger.error('get balance error', error)
      callback(error, null)
    
    })
    }

module.exports = {
doNemTransaction: doNemTransaction,
getNemBalance: getNemBalance
}

进行测试


const nem = require('./nem')

// test nem transfer
const testNemTransfer = () => {
let req = {}
req.body = {
name: ‘NEM’,
address: ‘TCKUVV6PETWYVUBGTWRCLUROJOJVPY4JZXNRSDKT’,
value: 8729700
}
nem.doNemTransaction(req, (error, res) => {
console.log(‘doNemTransaction nem’, error, res)
})
}
// test mosaic transfer
const testMosaicTransfer = () => {
let req = {}
req.body = {
name: ‘NPXSXEM’,
address: ‘TCKUVV6PETWYVUBGTWRCLUROJOJVPY4JZXNRSDKT’,
value: 548250000
}
nem.doNemTransaction(req, (error, res) => {
console.log(‘doNemTransaction mosaic’, error, res)
})
}
// test get balance
const testGetBalance = () => {
let req = {}
req.query = {
/* coin: ‘NEM’,
address: ‘TCKUVV6PETWYVUBGTWRCLUROJOJVPY4JZXNRSDKT’ */
coin: ‘NPXSXEM’,
address: ‘TDV75PQWM2RYWVDM6JAFSPNXR44U63B3I4HQOR6A’
}
nem.getNemBalance(req, (error, res) => {
console.log(‘getNemBalance’, error, res)
})
}

testNemTransfer()
testMosaicTransfer()
testGetBalance()

其它

账户多签也是NEM的一个重要功能。直白说就是,一个钱包由多个人共同管理,只有超多一半的人签名同意后才可以转账,多签可以将多个账户分散存储,加强资产的安全性。多签的实现和转账流程与上述的略有差别,感兴趣的可以自行研究。

NEM的对开发者来说算是比较友好,完善的文档(细节可以更加完善),多语言的SDK。缺点就是基础设施有点差,比如移动端的钱包,比较鸡肋。而且中文社区较少,中文的开发资料非常少。