0%


注解和反射是Android开发的基础,也是项目框架搭建中用到的必不可少的技术,减少重复代码编写,提高开发效率,并且广泛用于知名的开源框架中,有利于我们阅读源码,同时提升自己的架构能力和封装基础库的能力。下面对注解和反射的学习做一个记录。


原注解

元注解是定义注解的注解

@Retention:该注解保留阶段,保留的时长, 源码(RetentionPolicy.SOURCE) < 字节码(RetentionPolicy.CLASS) < 运行时(RetentionPolicy.RUNTIME)

  • 源码级别的注解:应用于APT编译期处理注解生成JAVA代码,生成额外的辅助类,如Dagger2, ButterKnife, EventBus3
  • 字节码级别的注解:应用于字节码插桩,可用于埋点,如ASM,AspectJ
  • 运行时级别的注解:反射获取被注解标记的变量/方法/类的信息

@Target:该注解被使用的位置,字段枚举常量级(ElementType.FIELD),局部变量级(ElementType.LOCAL_VARIABLE),方法级(ElementType.METHOD),方法级(ElementType.PARAMETER),类级接口级(ElementType.TYPE),包级(ElementType.PACKAGE),构造方法(ElementType.CONSTRUCTOR),注解级(ElementType.ANNOTATION_TYPE)

注解+反射实现Intent参数传递

定义注解

用反射获取该变量的信息需保留到运行时阶段且注解应用于类的字段变量之上

1
2
3
4
5
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface AutoInject {
String key();
}

注入逻辑

  1. 获取到该Activity的class对象,获取到该Activity的Intent数据
  2. 没有任何传值时直接返回
  3. 如果有参数传递,获取到该Activity的所有字段变量
  4. 确定每个字段变量的传值key,遍历所有的字段变量,判断该字段变量是否被注解,如果被注解则获取到注解对象,判断注解上的参数传值是否为空,如果为空直接使用被注解的变量名称为key,不为空则使用注解上的参数传值为key
  5. 判断传递的参数中是否有该key的值,如果有获取传入的值,如果字段变量不为数组,这里传入的值为最终结果
  6. 获取被注解的变量类型,如果该变量是数组并且是序列化的类,强转对象数组,并复制一份新的对象数组为最终结果,修改Activity中该变量的访问权限,将结果赋值给该变量
    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
    public static void injectBundle(Activity activity){
    // 获取class对象
    Class<? extends Activity> cls = activity.getClass();
    Intent intent = activity.getIntent();
    Bundle bundle = intent.getExtras();
    // 如果没有传值返回
    if (bundle == null){
    return;
    }
    // 获取所有的变量
    Field[] fields = cls.getDeclaredFields();
    // 遍历activity的变量
    for(Field field: fields){
    // 判断是否被注解
    if (field.isAnnotationPresent(AutoInject.class)){
    // 获取到注解对象
    AutoInject autoInject = field.getAnnotation(AutoInject.class);
    // 判断注解传值是否为空,如果为空使用当前被注解的变量名称
    String key = TextUtils.isEmpty(autoInject.key()) ? field.getName() : autoInject.key();
    // 如果有该key的传值
    if (bundle.containsKey(key)){
    // 获取传入的值
    Object object = bundle.get(key);
    // 获取被注解的变量类型
    Class<?> componentType = field.getType().getComponentType();
    // 如果当前变量是数组并且是序列化的class
    if (field.getType().isArray() && Parcelable.class.isAssignableFrom(componentType)){
    // 强转对象数组
    Object[] objs = (Object[])object;
    // 复制到新的对象数组
    Object[] objects = Arrays.copyOf(objs, objs.length, (Class<? extends Object[]>) field.getType());
    object = objects;
    }
    // 修改该变量的访问权限
    field.setAccessible(true);
    try {
    // 设置当前activity该变量的值为传值对象
    field.set(activity,object);
    } catch (IllegalAccessException e) {
    e.printStackTrace();
    }
    }
    }
    }
    }

    使用

  • 第一个Activity传递参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //User是序列化对象
    User user1 = new User("小明",12);
    User user2 = new User("小王",13);
    User[] users = new User[2];
    users[0] = user1;
    users[1] = user2;
    ArrayList<User> userList = new ArrayList<User>();
    userList.add(user1);
    userList.add(user2);
    // 传对象
    intent.putExtra("test1",user1);
    // 传对象数组
    intent.putExtra("test2",users);
    // 传对象列表
    intent.putParcelableArrayListExtra("test3",userList);
  • 第二个Activity声明接收变量添加注解
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 接收对象
    @AutoInject(key = "test1")
    private User value1;
    // 接收对象数组
    @AutoInject(key = "test2")
    private User[] value4;
    // 接收对象列表
    @AutoInject(key = "test3")
    private ArrayList<User> value5;
    // Activity创建时调注入逻辑
    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    InjectUtils.injectBundle(this);
    }

注解+反射实现View.OnClick注入逻辑

定义注解

定义注解的注解

声明监听器类型,注入的方法

1
2
3
4
5
6
7
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface EventType {

Class listenerType();
String listenerSetter();
}

定义方法注解

用反射获取该变量的信息需保留到运行时阶段且注解应用于方法之上

普通点击监听的类型为View.OnClickListener.class,作用的方法为setOnClickListener

1
2
3
4
5
6
7
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@EventType(listenerType = View.OnClickListener.class, listenerSetter = "setOnClickListener")
public @interface OnClick {
int[] value();

}

长按监听的类型为View.OnLongClickListener.class,作用的方法为setOnLongClickListener

1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@EventType(listenerType = View.OnLongClickListener.class, listenerSetter = "setOnLongClickListener")
public @interface OnLongClick {
int[] value();
}

注入逻辑

  1. 获取到该Activity的class对象,获取到当前Activity的所有方法
  2. 遍历所有方法,获取到方法的所有注解
  3. 遍历所有注解,获取到当前注解类型
  4. 如果是EventType目标注解,获取到注解对象,获取到注解上定义的传值,监听的Class类型,注解作用的方法
  5. 获取到方法上注解传入的id
  6. 修改方法的访问权限
  7. 利用Java的代理器生成代理对象,动态代理OnClickListener/OnLongClickListener接口
  8. 自定义InvocationHandler添加在对应的点击事件上注入的逻辑
  9. 获取到View对象,在对应的点击方法上注入代理对象
    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
    public class InjectClick {
    public static void injectEvent(Activity activity){
    // 获取到当前的activity的class对象
    Class<? extends Activity> activityClass = activity.getClass();
    // 获取到当前activity的所有方法
    Method[] methods = activityClass.getDeclaredMethods();
    // 遍历所有方法
    for (Method method: methods){
    // 获取到方法的所有注解
    Annotation[] annotations = method.getAnnotations();
    // 遍历所有注解
    for (Annotation annotation: annotations){
    // 获取到注解的类型
    Class<? extends Annotation> annotationType = annotation.annotationType();
    // 如果是EventType的注解
    if (annotationType.isAnnotationPresent(EventType.class)){
    // 获取到注解对象
    EventType eventType = annotationType.getAnnotation(EventType.class);
    // 获取到注解上定义的传值
    Class listenerType = eventType.listenerType();
    String listenerSetter = eventType.listenerSetter();
    try{
    // 获取到注解传入的id值
    Method valueMethod = annotationType.getDeclaredMethod("value");
    int[] viewIds = (int[]) valueMethod.invoke(annotation);
    method.setAccessible(true);
    ListenerInvocationHandler<Activity> handler = new ListenerInvocationHandler(activity, method);
    // OnClickListener/OnLongClickListener的代理对象
    Object listenerProxy = Proxy.newProxyInstance(listenerType.getClassLoader(),
    new Class[]{listenerType}, handler);

    // 遍历传入的id
    for (int viewId : viewIds) {
    // 获得view
    View view = activity.findViewById(viewId);
    // 获得OnClickListener/OnLongClickListener的setOnClickLisnter/setOnLongClickLisnter方法
    Method setter = view.getClass().getMethod(listenerSetter, listenerType);
    // 在View的点击方法上注入代理对象
    setter.invoke(view, listenerProxy);
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }
    }
    }


    /**
    * 兼容自定义view注入,所以是泛型: T = Activity/View
    *
    * @param <T>
    */
    static class ListenerInvocationHandler<T> implements InvocationHandler {

    private Method method;
    private T target;

    public ListenerInvocationHandler(T target, Method method) {
    this.target = target;
    this.method = method;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Log.v("injectClick","注入点击事件逻辑");
    return this.method.invoke(target, args);
    }
    }

使用

声明对应的点击回调,并添加注解,传入被注入点击事件View的Id

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
@OnClick({R.id.text, R.id.button})
public void click(View view) {
switch (view.getId()) {
case R.id.text:
Log.i("click", "click: 按钮1");
break;
case R.id.button:
Log.i("click", "click: 按钮2");
break;
}
}

@OnLongClick({R.id.text, R.id.button})
public boolean longClick(View view) {
switch (view.getId()) {
case R.id.text:
Log.i("click", "longClick: 按钮1");
break;
case R.id.button:
Log.i("click", "longClick: 按钮2");
break;
}
return false;
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
InjectClick.injectEvent(this);
}


又是很久没有写博客了,疫情结束后经济萧条,很多年轻人受疫情的影响而被迫离职至今未找到工作。今年受疫情影响,招聘需求明显萎缩,离职人员近几个月大幅增加,这应该是最难找工作的一年,相信不管是在职或离职的小伙伴应该都不太轻松。有工作经验的人尚且如此,今年毕业的应届生求职可能会更加艰难。生活不易,但是我们仍要以积极的心态去应对,相信终有一天这一切都会过去,生活又美好如初。受疫情影响今年我也经历了找工作这段痛苦的日子,受老天眷顾找到了一份新的工作,但仍然感觉自己的技术实力还需提高,作为程序员扎实的基础是核心竞争力中不可缺少的一部分,在扎实的基础下进一步扩展深度,学习更多的计算机底层原理,同时扩展广度,学习当下的新技术,只有保证自己的核心竞争力,才能在任何时候面对危机和考验从容应对。代理模式是设计模式中的常考点且很多开源框架都用到了这个模式,有必要学习并加深理解,在此做一个学习记录。


静态代理

外地拼搏的年轻人总是要面对租房的问题,这里以租房为例,理解静态代理模式。首先我们需要一个租赁接口,这个接口中只有一个方法就是租房

1
2
3
public interface Rent {
void rentHouse();
}

有一个年轻人叫小明,在外地拼搏的他需要租房,需要继承租赁这个接口并实现租房的方法

1
2
3
4
5
6
public class XiaoMing implements Rent{
@Override
public void rentHouse() {
Log.v("proxy","小明需要租房");
}
}

有一个中介机构链家可以帮你租房,你将租房需求告诉他们,当你满意成功租房后需要支付给他们中介费

1
2
3
4
5
6
7
8
9
10
11
12
13
public class LianJia implements Rent{

private Rent rent;
public LianJia(Rent rent){
this.rent = rent;
}
@Override
public void rentHouse() {
Log.v("proxy","链家获取你的租房需求");
rent.rentHouse();
Log.v("proxy","链家帮你租房");
}
}

接下来让链家帮小明租房

1
2
3
4
5
6
// 声明一个有租房需求的人小明
Rent xiaoMing = new XiaoMing();
// 声明中介机构链家,接受小明的租房需求
LianJia lianJia = new LianJia(xiaoMing);
// 链家帮小明租房
lianJia.rentHouse();

动态代理

Java中有个代理器可以实现接口对象的代理并生成对应的代理对象,我们利用Proxy.newProxyInstance生成实现了Rent接口的小明并生成代理对象,invoke方法中第一个参数是代理对象,第二个参数是被代理的方法,第三个参数是当前方法传入的参数值

1
2
3
4
5
6
Object o = Proxy.newProxyInstance(SplashActivity.class.getClassLoader(), new Class[]{Rent.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return method.invoke(xiaoMing,args);
}
});

将代理对象转化为Rent接口的实例并调用租房的方法

1
2
3
// 代理生成器的代理对象
Rent xiaoMingProxy = (Rent) o;
xiaoMingProxy.rentHouse();

手写Retrofit框架

Retrofit的核心就是通过动态代理,将注解参数拼接成一个完整的http请求再给网络请求框架去处理

自定义注解

Field

1
2
3
4
5
@Target(ElementType.PARAMETER) // 表单提交,作用在POST请求的参数上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface Field {
String value();
}

GET

1
2
3
4
5
@Target(ElementType.METHOD) // 声明GET请求,作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface GET {
String value();
}

POST

1
2
3
4
5
@Target(ElementType.METHOD) // 声明POST请求,作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface POST {
String value();
}

Query

1
2
3
4
5
@Target(ElementType.PARAMETER) // url上拼接,作用在请求的参数上
@Retention(RetentionPolicy.RUNTIME) // 运行期保留
public @interface Query {
String value();
}

实现Retrofit

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
public class MyRetrofit {
final Map<Method, ServiceMethod> serviceMethodMap = new ConcurrentHashMap<>(); // 缓存调用方法到方法参数解析服务的映射
final Call.Factory callFactory;// 网络请求框架
final HttpUrl baseUrl;// 请求服务器url地址

public MyRetrofit(Call.Factory callFactory, HttpUrl baseUrl) {
this.callFactory = callFactory;
this.baseUrl = baseUrl;
}

// 返回请求接口的代理对象
public <T> T create(final Class<T> service) {
return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class[]{service},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//解析方法所有注解信息
ServiceMethod serviceMethod = loadServiceMethod(method);
//传入参数的值并返回拼接好的请求
return serviceMethod.invoke(args);
}
});
}

// 双琐式创建实例
private ServiceMethod loadServiceMethod(Method method){
ServiceMethod serviceMethod = serviceMethodMap.get(method);
if (serviceMethod == null){
synchronized (serviceMethodMap){
// 直接取出对应的方法参数解析服务
serviceMethod = serviceMethodMap.get(method);
// 如果没有缓存就初始化调用,再放入缓存
if (serviceMethod == null){
serviceMethod = new ServiceMethod.Builder(this,method).build();
serviceMethodMap.put(method,serviceMethod);
}
}
}
return serviceMethod;
}

// 接收外部传入的参数并构建实例
public static final class Builder{
private HttpUrl baseUrl;
private Call.Factory callFactory;
public Builder callFactory(Call.Factory callFactory){
this.callFactory = callFactory;
return this;
}

public Builder baseUrl(String url){
this.baseUrl = HttpUrl.parse(url);
return this;
}

public MyRetrofit build(){
if (baseUrl == null){
throw new IllegalStateException("base url required");
}
Call.Factory callFactory = this.callFactory;
if(callFactory == null){
callFactory = new OkHttpClient();
}
return new MyRetrofit(callFactory, baseUrl);
}
}
}

实现方法参数解析服务

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
public class ServiceMethod {
private final Call.Factory callFactory;// 网络请求框架
private final String url; // 当前的请求路径
private final boolean hasBody; // 是否有请求体
private final ParameterHandler[] parameterHandlers;
private FormBody.Builder formBuild; // 请求体表单
HttpUrl baseUrl;// 请求服务器url
String httpMethod; // 请求方式post/get
HttpUrl.Builder urlBuilder;

public ServiceMethod(Builder builder) {
baseUrl = builder.retrofit.baseUrl;
callFactory = builder.retrofit.callFactory;

httpMethod = builder.httpMethod;
url = builder.url;
hasBody = builder.hasBody;
parameterHandlers = builder.parameterHandlers;

//如果有请求体,创建okhttp请求体对象
if (hasBody) {
formBuild = new FormBody.Builder();
}
}

// 处理传参
public Object invoke(Object[] args) {
// 处理请求的地址与参数
for (int i = 0; i < parameterHandlers.length; i++) {
ParameterHandler handlers = parameterHandlers[i];
//handler内本来就记录了key,现在给到对应的value
handlers.apply(this, args[i].toString());
}

//获取最终请求地址
HttpUrl httpUrl;
if (urlBuilder == null) {
urlBuilder = baseUrl.newBuilder(url);
}
httpUrl = urlBuilder.build();

//请求体
FormBody formBody = null;
if (formBuild != null) {
formBody = formBuild.build();
}

// 最后拼接成功的请求
Request request = new Request.Builder().url(httpUrl).method(httpMethod, formBody).build();
return callFactory.newCall(request);
}

// get请求, 按http的方式处理参数
public void addQueryParameter(String key, String value) {
if (urlBuilder == null) {
urlBuilder = baseUrl.newBuilder(url);
}
urlBuilder.addQueryParameter(key, value);
}

//Post请求, 按http的方式处理参数
public void addFiledParameter(String key, String value) {
formBuild.add(key, value);
}

public static class Builder{
private final MyRetrofit retrofit;
private final Annotation[] methodAnnotations;
private final Annotation[][] parameterAnnotations;
ParameterHandler[] parameterHandlers;
private String httpMethod;
private String url;
private boolean hasBody;

public Builder(MyRetrofit retrofit, Method method) {
this.retrofit = retrofit;
//获取方法的所有注解
methodAnnotations = method.getAnnotations();
//获取方法参数的所有注解
parameterAnnotations = method.getParameterAnnotations();
}

public ServiceMethod build() {

//处理POST与GET
for (Annotation methodAnnotation : methodAnnotations) {
if (methodAnnotation instanceof POST) {
//记录请求方式
this.httpMethod = "POST";
//记录请求url的path
this.url = ((POST) methodAnnotation).value();
// 是否有请求体
this.hasBody = true;
} else if (methodAnnotation instanceof GET) {
this.httpMethod = "GET";
this.url = ((GET) methodAnnotation).value();
this.hasBody = false;
}
}

// 处理方法参数的注解
int length = parameterAnnotations.length;
// 创建请求参数映射数组
parameterHandlers = new ParameterHandler[length];
for (int i = 0; i < length; i++) {
// 一个参数的所有注解
Annotation[] annotations = parameterAnnotations[i];
// 处理每一个注解
for (Annotation annotation : annotations) {
// 如果是Field注解
if (annotation instanceof Field) {
//得到注解上的value也就是请求参数的key
String value = ((Field) annotation).value();
// 传入参数的key
parameterHandlers[i] = new ParameterHandler.FieldParameterHandler(value);
}
// 如果是Query注解
else if (annotation instanceof Query) {
String value = ((Query) annotation).value();
parameterHandlers[i] = new ParameterHandler.QueryParameterHandler(value);

}
}
}

return new ServiceMethod(this);
}
}

}
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
public abstract class ParameterHandler {
abstract void apply(ServiceMethod serviceMethod, String value);
static class QueryParameterHandler extends ParameterHandler{
String key;
public QueryParameterHandler(String key){
this.key = key;
}

@Override
void apply(ServiceMethod serviceMethod, String value) {
serviceMethod.addQueryParameter(key,value);
}
}

static class FieldParameterHandler extends ParameterHandler{
String key;
public FieldParameterHandler(String key) {
this.key = key;
}

@Override
void apply(ServiceMethod serviceMethod, String value) {
serviceMethod.addFiledParameter(key,value);
}
}
}

定义网络请求Api

1
2
3
4
5
6
7
public interface WeatherApi {
@POST("/v3/weather/weatherInfo")
Call postWeather(@Field("city") String city, @Field("key") String key);

@GET("/v3/weather/weatherInfo")
Call getWeather(@Query("city") String city, @Query("key") String key);
}

自定义Retrofit发起请求

初始化Retrofit

1
2
MyRetrofit myRetrofit = new MyRetrofit.Builder().baseUrl("https://restapi.amap.com").build();
weatherApi = myRetrofit.create(WeatherApi.class);

发起post请求

1
2
3
4
5
6
7
8
9
10
11
12
13
okhttp3.Call getCall = weatherApi.getWeather("110101", "ae6c53e2186f33bbf240a12d80672d1b");
getCall.enqueue(new okhttp3.Callback() {
@Override
public void onFailure(okhttp3.Call call, IOException e) {

}

@Override
public void onResponse(okhttp3.Call call, okhttp3.Response response) throws IOException {
Log.i("onResponse", "onResponse enjoy get: " + response.body().string());
response.close();
}
});

发起get请求

1
2
3
4
5
6
7
8
9
10
11
12
13
okhttp3.Call postCall = weatherApi.postWeather("110101", "ae6c53e2186f33bbf240a12d80672d1b");
postCall.enqueue(new okhttp3.Callback() {
@Override
public void onFailure(okhttp3.Call call, IOException e) {

}

@Override
public void onResponse(okhttp3.Call call, okhttp3.Response response) throws IOException {
Log.i("onResponse", "onResponse enjoy post: " + response.body().string());
response.close();
}
});


又是很久没有写博客了,最近由于武汉疫情,这个春节从初一起就全程宅家,春节假期一延再延,本该上班的我们依然没有正常返工,街上仍然是没有几个人,快递延迟,很多人仍然是宅在家里远程上班。停工不停学,之前的漫画项目主要使用了网易漫画和腾讯漫画爬取的数据,而网易漫画在不久前被bilibili收购了,现在正式改为bilibili漫画,所以之前的爬虫逻辑和接口失效了,正好趁着这个时间把之前的服务端数据爬取接口改一下,这里做一个简单的记录。


爬取漫画列表和漫画详情都没什么问题,跟之前的思路一样,改一下对应的标签重新绑定目标数据,但是在爬去漫画内容的时候,发现漫画图片的链接已经不在html的标签中了,而是直接获取到服务端返回的图片地址后用canvas绘制出来的。如下图所示:
截图

所以只要我们能获取到该页面的网络请求结果,我们就能过滤出图片地址,也就不用去标签中获取目标数据了,接下来我发现在chrome浏览器中元素审查界面的网络拦截器中可以找到漫画内容的图片链接,如下图所示:
截图

所以只要我们目前使用的爬虫框架puppeteer能够拦截到网络请求的结果就可以解决标签中无法爬取到图片地址的问题了。我查了一下puppeteer的官方文档,发现了这些api

开启拦截

page.setRequestInterception(true)

监听服务端返回
page.on('response')

另外还可以监听当前页面的请求

page.on('request')

返回一个自定义的响应

req.respond()

根据当前的场景,我们需要获取服务器返回的数据,并过滤其中的漫画图片地址

https://manga.hdslb.com/bfs/manga/a39f3fd06e540fe14b7e591ced413f372bd9f85f.jpg@660w.jpg?token=3a96fd02961137c00a76145fb381d544&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/4cd38fde6581e146c249373c9ed120b75047004a.jpg@660w.jpg?token=2e740fd7ccfefec3f1f5d0d27e925e33&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/18a6e2e4739e7e3eb9888e7220b398fb2d0def9d.jpg@660w.jpg?token=0e8b573c3c40fa3c5554d2df6ec8b2cb&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/e6cbe3162d3c4b6e1175557a90d4a0e54562032f.jpg@660w.jpg?token=0d1a55fe1d27d3527a9340034cd5a35f&ts=5e3a7d62

https://manga.hdslb.com/bfs/manga/0c649ad9107997801dd4e45179323381b16dc50a.jpg@660w.jpg?token=fbdf6eb7df231dbe345f4411a32d56c6&ts=5e3a7d62

以上的链接地址特征

  1. https://manga.hdslb.com/bfs/manga/开头
  2. 尾部都跟有token和ts的参数,?token=&ts=
  3. @660w.jpg看起来是传入了请求图片的宽度和图片格式
  4. 抛开@后面的尾部参数,请求的图片格式和@后面的尾部参数传入的格式一致

根据特征,抓取漫画图片代码如下:

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
const puppeteer = require('puppeteer')
const browser = await puppeteer.launch({
headless: false,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
})
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('response',
function (response){
let url = response.url().toString()
let tokenStart = url.indexOf('?token=')
let tsStart = url.indexOf('&ts=')
// 捕获目标url
if (url.indexOf('manga.hdslb.com/bfs/manga/') !== -1
&& tokenStart !== -1
&& tsStart !== -1){
// 尾部参数请求的格式
let imgParamStart = url.indexOf('@')
if (imgParamStart !== -1){
// 图片本身的格式
let suffix = url.substring(imgParamStart - 3, imgParamStart)
// 请求图片的参数
let imgParam = url.substring(imgParamStart, tokenStart)
let imgWidthEnd = imgParam.indexOf('w')
// 请求图片的宽度
let imgWidth = imgParam.substring(1, imgWidthEnd)
let imgFormatStart = imgParam.indexOf('.')
// 请求图片的格式
let imgFormat = imgParam.substring(imgFormatStart + 1,tokenStart)
// 过滤图片信息
if (suffix === imgFormat){
console.log(response.url())
let data = response.url().toString();
let imgHeight = 1320
resolve({data, imgWidth, imgHeight})
}
}
}
}
)
// 跳转到目标网站
await page.goto(url)


又是很长一段时间没写博客了,最近工作繁忙,加班多,也是无奈啊~但是好习惯还是应该坚持下去的,平时工作钉钉作为主要的沟通工具,发现它除了是个聊天软件以外,还有一个好玩的东西-钉钉机器人,做些自动化推送提醒还是不错的,之前在telegram看到过类似的东西,它有一套专属api,可发送一些自定义的消息,实现一些自动化功能,目前推送提醒的解决方案一般是app推送,短信,企业微信,邮件,为了推送提醒单独开发一个app成本太高,短信现在几乎都是收费的,企业微信注册麻烦,而邮件一般都不会及时去看的。钉钉机器人创建成本低,又是主力聊天工具之一,对于个人或群组推送还是很实用的,随便拉两个人创建一个群就可以添加机器人了,如果只是做个人提醒的话,创建好后可以把这两个人T掉。唯一的限制是1秒最多发送20条~由于个人用iphone手机,本土化做得不好,节假日后补班那几天经常因为忘记定闹钟而睡过头,又不想下载第三方app,准备用家里有个树莓派做个人小型服务器,每天晚上定时跑一个python脚本,提醒我明天是否上班,如果上班提醒我设好闹钟,并请求天气预报,如果明天上班且下雨,提醒我闹钟提前半个小时,下雨早点出门不堵啊。


创建钉钉机器人

只要是个群组即可创建钉钉机器人,先拉两个人组成群组,在群组菜单中选择群组助手,添加机器人选择自定义机器人,创建的时候填写机器人名字,这里需要复制webhook的url链接,安全设置中可勾选自定义关键字,签名,ip地址,这里为了简单选择自定义关键字,设置为“提醒”,只要发送内容中带了关键字“提醒”即可。如果选择签名的话需要参考官方的签名算法,签名需添加到webhook的url上,如果选择ip地址的话,只有该ip地址的服务器才可以调用api发送消息。

钉钉机器人发送消息

发起post请求,参数为json格式,就是机器人发送的内容,请求的地址就是创建机器人的webhook,如果安全设置选择了签名的话要带上签名参数。

1
2
3
4
5
6
7
8
import requests
import json
def messageRobot(msg):
url = '你的webhook'
headers = {
'Content-Type': 'application/json'
}
requests.post(url, data=json.dumps(msg), headers=headers)

获取日期信息

找了一个免费api,http://timor.tech/api/holiday/info/{yyyy-MM-dd},{yyyy-MM-dd}为要查询的日期,请求结果如下:
如果该接口请求失败,code = 1,也需要给自己发送接口请求失败的消息,及时处理
type = 0, 明天是正常的工作日
request url:http://timor.tech/api/holiday/info/2019-10-11

1
2
3
4
5
6
7
8
9
{
"code": 0,
"type": {
"type": 0,
"name": "周五",
"week": 5
},
"holiday": null
}

type = 1, 明天是正常的双休日
request url: http://timor.tech/api/holiday/info/2019-10-13

1
2
3
4
5
6
7
8
9
{
"code": 0,
"type": {
"type": 1,
"name": "周日",
"week": 7
},
"holiday": null
}

type = 2, 明天是法定节假日
request url:http://timor.tech/api/holiday/info/2019-10-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"code": 0,
"type": {
"type": 2,
"name": "国庆节",
"week": 2
},
"holiday": {
"holiday": true,
"name": "国庆节",
"wage": 3,
"date": "2019-10-01"
}
}

type = 3, 明天是补班的特殊日子
request url:http://timor.tech/api/holiday/info/2019-10-12

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"code": 0,
"type": {
"type": 3,
"name": "国庆节后调休",
"week": 6
},
"holiday": {
"holiday": false,
"name": "国庆节后调休",
"after": true,
"wage": 1,
"target": "国庆节",
"date": "2019-10-12"
}
}

获取天气预报

也是找了一个免费Api,http://t.weather.sojson.com/api/weather/city/{citycode},参数是城市编码,以成都为例,请求结果如下:

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
{
"message": "success感谢又拍云(upyun.com)提供CDN赞助",
"status": 200,
"date": "20200307",
"time": "2020-03-07 22:52:50",
"cityInfo": {
"city": "成都市",
"citykey": "101270101",
"parent": "四川",
"updateTime": "22:30"
},
"data": {
"shidu": "63%",
"pm25": 52.0,
"pm10": 79.0,
"quality": "良",
"wendu": "14",
"ganmao": "极少数敏感人群应减少户外活动",
"forecast": Array[15][
{
"date": "07",
"high": "高温 19℃",
"low": "低温 12℃",
"ymd": "2020-03-07",
"week": "星期六",
"sunrise": "07:25",
"sunset": "19:06",
"aqi": 94,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "08",
"high": "高温 18℃",
"low": "低温 11℃",
"ymd": "2020-03-08",
"week": "星期日",
"sunrise": "07:24",
"sunset": "19:07",
"aqi": 57,
"fx": "无持续风向",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "09",
"high": "高温 17℃",
"low": "低温 8℃",
"ymd": "2020-03-09",
"week": "星期一",
"sunrise": "07:22",
"sunset": "19:08",
"aqi": 52,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "10",
"high": "高温 18℃",
"low": "低温 8℃",
"ymd": "2020-03-10",
"week": "星期二",
"sunrise": "07:21",
"sunset": "19:08",
"aqi": 59,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "11",
"high": "高温 15℃",
"low": "低温 9℃",
"ymd": "2020-03-11",
"week": "星期三",
"sunrise": "07:20",
"sunset": "19:09",
"aqi": 55,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "12",
"high": "高温 17℃",
"low": "低温 11℃",
"ymd": "2020-03-12",
"week": "星期四",
"sunrise": "07:19",
"sunset": "19:10",
"aqi": 62,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "13",
"high": "高温 15℃",
"low": "低温 11℃",
"ymd": "2020-03-13",
"week": "星期五",
"sunrise": "07:18",
"sunset": "19:10",
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "14",
"high": "高温 20℃",
"low": "低温 12℃",
"ymd": "2020-03-14",
"week": "星期六",
"sunrise": "07:16",
"sunset": "19:11",
"fx": "东北风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "15",
"high": "高温 15℃",
"low": "低温 11℃",
"ymd": "2020-03-15",
"week": "星期日",
"sunrise": "07:15",
"sunset": "19:12",
"fx": "东北风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "16",
"high": "高温 14℃",
"low": "低温 9℃",
"ymd": "2020-03-16",
"week": "星期一",
"sunrise": "07:14",
"sunset": "19:12",
"fx": "东北风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "17",
"high": "高温 16℃",
"low": "低温 10℃",
"ymd": "2020-03-17",
"week": "星期二",
"sunrise": "07:13",
"sunset": "19:13",
"fx": "南风",
"fl": "<3级",
"type": "小雨",
"notice": "雨虽小,注意保暖别感冒"
},
{
"date": "18",
"high": "高温 20℃",
"low": "低温 9℃",
"ymd": "2020-03-18",
"week": "星期三",
"sunrise": "07:12",
"sunset": "19:14",
"fx": "南风",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "19",
"high": "高温 22℃",
"low": "低温 11℃",
"ymd": "2020-03-19",
"week": "星期四",
"sunrise": "07:10",
"sunset": "19:14",
"fx": "东南风",
"fl": "<3级",
"type": "阴",
"notice": "不要被阴云遮挡住好心情"
},
{
"date": "20",
"high": "高温 23℃",
"low": "低温 12℃",
"ymd": "2020-03-20",
"week": "星期五",
"sunrise": "07:09",
"sunset": "19:15",
"fx": "东北风",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
},
{
"date": "21",
"high": "高温 22℃",
"low": "低温 13℃",
"ymd": "2020-03-21",
"week": "星期六",
"sunrise": "07:08",
"sunset": "19:16",
"fx": "西北风",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
}
],
"yesterday": {
"date": "06",
"high": "高温 16℃",
"low": "低温 10℃",
"ymd": "2020-03-06",
"week": "星期五",
"sunrise": "07:26",
"sunset": "19:06",
"aqi": 79,
"fx": "无持续风向",
"fl": "<3级",
"type": "多云",
"notice": "阴晴之间,谨防紫外线侵扰"
}
}
}

如果请求成功status=200,data的forecast字段是包括今天以及未来14天的天气情况,所以这个字段下的第二个元素就是明天的天气预报,判断该元素下的type字段是否包含“雨”,并返回调用结果,如果接口请求失败也给自己发送一条消息,及时处理

python3实现

每天定时跑脚本,给自己发钉钉消息,并结合明天的天气预报,如果要下雨给自己发送要早起的提示

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
import json
import requests
import datetime
import time
def getWeather():
url = "http://t.weather.sojson.com/api/weather/city/{citycode}"
response = requests.get(url)
logWithTime("getWeather ---> " + response.text)
json_data = json.loads(response.text)
status = json_data.get('status')
if status == 200:
# 明天的天气信息
tomorrow = json_data.get('data').get('forecast')[1]

# 明天的天气
tomorrow_weather = str(tomorrow.get("type"))
if "雨" in tomorrow_weather:
return 1
else:
return 0
else:
return -1

def getDateInfo():
#获得今天的日期
today = datetime.date.today()
#获得明天的日期
tomorrow = today + datetime.timedelta(days=1)
url = "http://timor.tech/api/holiday/info/" + str(tomorrow)
response = requests.get(url)
logWithTime("getDateInfo ---> " + response.text)
json_data = json.loads(response.text)
dayType = json_data.get('type').get('type')
code = json_data.get('code')
holiday = json_data.get('holiday')

if code == 1:
logWithTime("getDateInfoError ---> ")
requestError(True)
else:
if(dayType == 0): #正常上班
messageWorkNormal(getWeather())
elif(dayType == 1 or dayType == 2): #普通周末和节假日
messageHoliday()
elif(dayType == 3): # 补假日
messageWorkAbnormal(today, str(holiday.get('name')),getWeather())



# 休息日
def messageHoliday():
logWithTime("messageHoliday ---> ")
messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】明天休息日,不上班哦~"
}
})

# 正常上班
def messageWorkNormal(rain):
if rain == -1:
logWithTime("getWeatherError ---> ")
requestError(False)
else:
str = ""
if rain == 1:
logWithTime("messageWorkNormal rain ---> ")
str = "可能下雨,需提前出门,"
logWithTime("messageWorkNormal ---> ")
messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】明天要上班,"+ str +"注意添加闹钟!!!"
}
})


# 接口请求出错处理
def requestError(date):
str = ""
if date:
str = "日期信息"
else:
str = "天气信息"

messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】"+str+"接口请求失败,注意添加闹钟!!!"
}
})

# 补假要上班
def messageWorkAbnormal(date, reason, rain):
if rain == -1:
logWithTime("getWeatherError ---> ")
requestError(False)
else:
str = ""
if rain == 1:
logWithTime("messageWorkAbnormal rain ---> ")
str = "可能下雨,需提前出门,"
logWithTime("messageWorkAbnormal ---> ")
messageRobot({
"msgtype": "text",
"text": {
"content": "【闹钟提醒】明天是:{},{} 要上班,".format(date,reason)+ str + "注意添加闹钟!!!"
}
})


# 钉钉机器人发送消息
def messageRobot(msg):
url = 'your webhook'

headers = {
'Content-Type': 'application/json'
}
requests.post(url, data=json.dumps(msg), headers=headers)

# 日志带时间利于排查
def logWithTime(msg):
localtime = time.asctime( time.localtime(time.time()) )
print (localtime + " " + msg)


if __name__ == "__main__":
getDateInfo()

每日晚上11点23在服务端运行这个脚本,请求钉钉机器人接口,给自己发送一个钉钉推送提醒,要定好闹钟

树莓派设置定时任务

将python脚本上传到树莓派,用ftp,ssh,samba都可以,这里就不详细说明了,设置定时任务前需要注意的是校准树莓派的时间,树莓派默认采用欧洲时区,如果树莓派的时间校准过,可略过此步骤。

校准树莓派时间

查看当前树莓派时间date
设置树莓派时区
sudo dpkg-reconfigure tzdata
选择亚洲时区Asia,选择上海时间Shanghai

contab设置定时任务

linux定时任务可利用contab设置,crontab -e,进入文件编辑,选择编辑工具,nano或vim
格式为:Minute Hour Day Month Dayofweek command
Minute 每个小时的第几分钟执行该任务
Hour 每天的第几个小时执行该任务
Day 每月的第几天执行该任务
Month 每年的第几个月执行该任务
DayOfWeek 每周的第几天执行该任务
Command 要执行的命令
设置23点22分执行python3的脚本,并输出日志,利于维护和问题排查
22 23 * * * python3 /home/pi/upload/test123.py >>/home/pi/mylog.log
保存文件,并重启contab服务
sudo service cron restart

ip变化提醒(2020/03/07更新)

家里申请了电信的公网ip,之前买了域名,一直在用端口转发+动态域名服务访问家里的树莓派,最近域名过期了,不打算续了,但希望仍然能在外网访问到家里的树莓派,电信的公网ip一直都是变动的,让电信固定ip需要繁杂的手续且需要企业申请,所以是不可能的了。最后想了一个可行的方法,如果能利用钉钉机器人在ip变化时给自己发送一条消息,告知最新的ip,那么仍然可以访问到家里的树莓派,这个问题就解决了。那如何知道ip变化了呢?还是利用linux的定时任务,每个小时跑一次python脚本,获取当前的外网ip,并将第一次的结果写入本地文件记录下来,每次运行脚本将当前外网ip与上一次记录的外网ip对比,如果不一致则ip变化,给自己发送钉钉消息告知最新的ip并再次写入文件刷新本地记录,如果ip一致则不用发送。

获取本机外网ip

找了一个免费的接口,http://members.3322.org/dyndns/getip,直接返回本机外网ip,如果为空就请求失败了,也给自己发送钉钉消息,及时处理,python实现如下:

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
import json
import requests
import os
import time
# 获取当前ip
def getip():
url = "http://members.3322.org/dyndns/getip"
response = requests.get(url)
logWithTime ("getip --->" + response.text)
myip = response.text.strip()
return myip

# 接口请求出错处理
def requestError():
messageRobot({
"msgtype": "text",
"text": {
"content": "【ip变更提醒】获取ip接口请求失败!!!"
}
})

def writeFile(ip):
fo = open("ip.txt", "w")
fo.write( ip)

def readFile():
fo = open("ip.txt", "r+")
return fo.read()

# 钉钉机器人发送消息
def messageRobot(msg):
url = 'your webhook'

headers = {
'Content-Type': 'application/json'
}
requests.post(url, data=json.dumps(msg), headers=headers)

# 开启ip变更通知任务
def notifyIpTask():
if os.path.exists("ip.txt"):
lastIp = readFile()
else:
lastIp = ""
logWithTime("notifyIpTask ---> notify ip server start")
myIp = getip()
if myIp == "":
logWithTime("notifyIpTask ---> get ip error")
requestError()
elif myIp == lastIp:
logWithTime("notifyIpTask ---> same ip")
else:
logWithTime("notifyIpTask ---> send ip")
writeFile(myIp)
messageRobot({
"msgtype": "text",
"text": {
"content": "【ip变更提醒】当前ip为{}".format(myIp)
}
})

# 日志带时间利于排查
def logWithTime(msg):
localtime = time.asctime( time.localtime(time.time()) )
print (localtime + " " + msg)

if __name__ == "__main__":
notifyIpTask()


最近公司有个需求,希望在不同的Android设备上实现视频的播放同步,误差尽可能的小,后期继续优化实现逐帧同步效果,以便后期进行矩阵拼接屏的研发,前期处于试探性阶段,在App上实现,目前误差在10-20ms内,属于肉眼完全看不出的误差效果范围,只有利用手机的慢动作摄影才能看出一点过渡误差,并且视频播放出的声音听起来也完全同步。对于请求主机时间消息的传递误差,主要利用了ntp时间同步技术来解决,对于视频的加载耗时通过两个播放器实例交替加载,达到预先加载的效果来解决,加载视频耗时造成的误差基本可以忽略不计。只要获取到了当前的主机时间,那么就能推算出主机的播放时间,这个时间可以看作是所有设备同步播放的时间,也能算出当前设备时间到这个同步播放时间的偏移量,达到准时播放同一个视频的效果。由于设备不一定一直联网,排除设备的时钟时间错误造成的干扰,这里统一采用rtc时间(硬件时钟)。这里主要记录一下在Android平台上ntp协议的应用。


NTP协议简介

网络时间协议NTP(Network Time Protocol)用于将计算机客户或服务器的时间与另一服务器同步,使用层次式时间分布模型。在配置时,NTP可以利用冗余服务器和多条网络路径来获得时间的高准确性和高可靠性。即使客户机在长时间无法与某一时间服务器相联系的情况下,仍可提供高准确度时间。

联网计算机同步时钟最简便的方法是网络授时。网络授时分为广域网授时和局域网授时。广域网授时精度通常能达50ms级,但有时超过500ms,这是因为每次经过的路由器路径可能不相同。现在还没有更好的办法将这种不同路径延迟的时间误差完全消除。局域网授时不存在路由器路径延迟问题,因而授时精度理论上可以提到亚毫秒级。Windows内置NTP服务,在局域网内其最高授时精度也只能达10ms级。

进一步提高NTP授时精度的方法

局域网络延相对较大的原因在于时间戳的发送请求和接收一般都是在应用层。减少操作系统内核处理延时可以提高NTP授时精度,使发/收NTP包时间戳应尽量接近主机真实发/收包时刻。在不改变硬件的条件下,修改网卡驱动程序,将记录NTP包发/收时间戳从应用程序移至网卡驱动程序处,可消除操作系统内核处理延时不确定而造成的误差。这种方法在局域网中可大幅提高NTP授时精度至μs级。

NTP的算法推导

NTP最典型的授时方式是Client/Server方式。如下图所示,客户机首先向服务器发送一个NTP包,其中包含了该包离开客户机的时间戳T1,当服务器接收到该包时,依次填入包到达的时间戳T2、包离开的时间戳T3,然后立即把包返回给客户机。客户机在接收到响应包时,记录包返回的时间戳T4。客户机用上述4个时间参数就能够计算出2个关键参数:NTP包的往返延迟d和客户机与服务器之间的时钟偏差t。客户机使用时钟偏差来调整本地时钟,以使其时间与服务器时间一致。

T1为客户发送NTP请求时间戳(以客户时间为参照);T2为服务器收到NTP请求时间戳(以服务器时间为参照);T3为服务器回复NTP请求时间戳(以服务器时间为参照);T4为客户收到NTP回复包时间戳(以客户时间为参照);d1为NTP请求包传送延时,d2为NTP回复包传送延时;t为服务器和客户端之间的时间偏差,d为NTP包的往返时间
那么服务器的处理时间为T3 - T2,服务器从发送请求到接收应答总时间为T4 - T1
例如T1客户发送NTP请求时间为10:00:00am,T2服务器收到NTP请求时间为11:00:01am,T3服务器回复NTP请求时间为11:00:02am,T4客户端收到NTP回复包10:00:03am,那么
d = (T4 - T1) - (T3 - T2) = 2秒
客户端设备相对服务端设备的时间差为
t = (T2 - T1) + (T3 - T4) / 2 约等于 1 小时

Android代码实现

客户端请求ntp时间

已知客户端T1,T4时间,实现从服务器返回的报文中获取T2,T3时间参数并计算服务器ntp时间

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
   private static final int NTP_PACKET_SIZE = 48;
private static final int NTP_MODE_CLIENT = 3;
private static final int NTP_VERSION = 3;
private static final int RECEIVE_TIME_OFFSET = 32;
private static final int TRANSMIT_TIME_OFFSET = 40;
private static final long OFFSET_1900_TO_1970 = ((365L * 70L) + 17L) * 24L * 60L * 60L;
/**
* 请求ntp服务器设备的时间,设置服务器IP地址,端口号,以及超时时间
*
* @param ipAddress ip地址
* @param port 端口号
* @param timeout 超时时间单位毫秒
* @return 如果请求成功返回true
*/
private boolean requestTime(String ipAddress, int port, int timeout) {
try {
InetAddress address = InetAddress.getByName(ipAddress);
DatagramSocket mSocket = new DatagramSocket();
mSocket.setSoTimeout(timeout);
byte[] buffer = new byte[NTP_PACKET_SIZE];
DatagramPacket request = new DatagramPacket(buffer, buffer.length, address, port);
buffer[0] = NTP_MODE_CLIENT | (NTP_VERSION << 3);
// 请求发送时间
final long requestTicks = SystemClock.elapsedRealtime();
mSocket.send(request);
// 读取服务器响应数据包
DatagramPacket response = new DatagramPacket(buffer, buffer.length);
mSocket.receive(response);
// 收到服务器响应时间
final long responseTicks = SystemClock.elapsedRealtime();
// 服务器的接收时间
final long receiveTime = readTimeStamp(buffer, RECEIVE_TIME_OFFSET);
// 服务器的发送时间
final long transmitTime = readTimeStamp(buffer, TRANSMIT_TIME_OFFSET);
// 计算时间客户端和服务器的时间差(服务器收到请求时间 - 客户端请求时间) + (服务器回复请求时间 - 客户端收到回复时间) / 2
long clockOffset = ((receiveTime - requestTicks) + (transmitTime - responseTicks)) / 2;
// 当前ntp服务器的时间
long mNtpTime = SystemClock.elapsedRealtime() + clockOffset;
// 添加到平均值计算集合中,求平均值使得计算误差更小
addDeviation(clockOffset);
} catch (Exception e) {
return false;
} finally {
try {
if (mSocket != null) {
if (!mSocket.isClosed()) {
mSocket.close();
}
mSocket.disconnect();
mSocket = null;
}
} catch (Exception e) {
e.printStackTrace();
}
}
return true;
}

/**
* 从buffer中读取32位的大端数
*/
private long read32(byte[] buffer, int offset) {
byte b0 = buffer[offset];
byte b1 = buffer[offset + 1];
byte b2 = buffer[offset + 2];
byte b3 = buffer[offset + 3];
// convert signed bytes to unsigned values
int i0 = ((b0 & 0x80) == 0x80 ? (b0 & 0x7F) + 0x80 : b0);
int i1 = ((b1 & 0x80) == 0x80 ? (b1 & 0x7F) + 0x80 : b1);
int i2 = ((b2 & 0x80) == 0x80 ? (b2 & 0x7F) + 0x80 : b2);
int i3 = ((b3 & 0x80) == 0x80 ? (b3 & 0x7F) + 0x80 : b3);

return ((long) i0 << 24) + ((long) i1 << 16) + ((long) i2 << 8)
+ (long) i3;
}

/**
* 从buffer中读取时间戳
* 返回毫秒数
*/
private long readTimeStamp(byte[] buffer, int offset) {
// 读取秒数
long seconds = read32(buffer, offset);
// 读取小数
long fraction = read32(buffer, offset + 4);
return ((seconds - OFFSET_1900_TO_1970) * 1000)
+ ((fraction * 1000L) / 0x100000000L);
}

这里的SystemClock.elapsedRealtime()是获取设备启动后到现在的时间(硬件时间),System.currentTimeMillis()获取的是系统时间(软件时间),是距离1970年1月1日开始计算的一个值,这里为了减少误差,避免因为外界修改软件时间而导致时间不准的情况而使用硬件时间。

服务器返回ntp时间

从客户端发的报文中获取响应地址和端口号,把收到请求的时间写入报文并保存到链表,从链表中取出刚才放入的报文,写入发送响应的时间发给客户端

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
private static final long OFFSET_1900_TO_1970 = ((365L * 70L) + 17L) * 24L * 60L * 60L;
private static final int RECEIVE_TIME_OFFSET = 32;
private static final int TRANSMIT_TIME_OFFSET = 40;
private static final int NTP_PACKET_SIZE = 48;
private static final int NTP_MODE_SERVIER = 0;
private static final int NTP_VERSION = 3;
public void start(int port) {
mCompositeDisposable.add(Observable.just(1).subscribeOn(Schedulers.io())
.subscribe(integer -> {
start = true;
Logger.t(TAG).d("服务端开启 port=" + port);
try {
mSocket = new DatagramSocket(null);
// 设置端口重用
mSocket.setReuseAddress(true);
// 绑定端口号
mSocket.bind(new InetSocketAddress(port));
while (start) {
try {
byte[] buffer = new byte[NTP_PACKET_SIZE];
DatagramPacket response = new DatagramPacket(buffer,
buffer.length);
buffer[0] = NTP_MODE_SERVIER | (NTP_VERSION << 3);
Logger.t(TAG).d("等待接收客户端消息");
mSocket.receive(response);
DatagramPacket echo = new DatagramPacket(buffer, buffer.length, response.getAddress(), response.getPort());
Logger.t(TAG).d("收到客户端请求=" + response.getAddress());
// 把收到的时间写入buffer
writeTimeStamp(buffer, RECEIVE_TIME_OFFSET, SystemClock.elapsedRealtime());
// 添加到链表
mLinkedList.add(echo);
} catch (IOException e) {
Logger.t(TAG).e("while receive failed:" + e);
}
}
} catch (Exception e) {
Logger.t(TAG).e("response failed:" + e);
} finally {
Logger.t(TAG).e("关闭socket");
if (mSocket != null) {
if (!mSocket.isClosed()) {
mSocket.close();
}
mSocket.disconnect();
mSocket = null;
}
}
}));
mCompositeDisposable.add(Observable.just(1).subscribeOn(Schedulers.io())
.subscribe(integer -> {
mSendSocket = new DatagramSocket();
mSendSocket.setSoTimeout(2000);
while (start) {
try {
if (mLinkedList.size() > 0) {
// 取出刚才放入链表的数据包
DatagramPacket first = mLinkedList.removeFirst();
if (first != null) {
// 写入发送响应包的时间
writeTimeStamp(first.getData(), TRANSMIT_TIME_OFFSET, SystemClock.elapsedRealtime());
Logger.t(TAG).d("向客户端发送消息=" + first.getAddress());
mSendSocket.send(first);
}
}
} catch (IOException e) {
Logger.t(TAG).e("while send failed:" + e);
}
}
Logger.t(TAG).d("发送消息端关闭");
}));
}

/**
* 写入硬件时间到buffer中
*/
private void writeTimeStamp(byte[] buffer, int offset, long time) {
long seconds = time / 1000L;
long milliseconds = time - seconds * 1000L;
seconds += OFFSET_1900_TO_1970;

// 按大端模式写入秒数
buffer[offset++] = (byte) (seconds >> 24);
buffer[offset++] = (byte) (seconds >> 16);
buffer[offset++] = (byte) (seconds >> 8);
buffer[offset++] = (byte) (seconds >> 0);

long fraction = milliseconds * 0x100000000L / 1000L;
// 按大端模式写入小数
buffer[offset++] = (byte) (fraction >> 24);
buffer[offset++] = (byte) (fraction >> 16);
buffer[offset++] = (byte) (fraction >> 8);
// 低位字节随机
buffer[offset++] = (byte) (Math.random() * 255.0);
}


家里面一直使用的MAC电脑最近遇到一个比较烦人的问题,浏览器被劫持,打开chrome浏览器和safari浏览器主页全是AnySearch的搜索导航界面,被一个名为AnySearch的插件恶意修改了主页,主页修改劫持,最开始我手动删除chrome和safari的插件,当时主页被修正回来了,但是过不了几天这个插件又悄悄的被安装在chrome和safari浏览器上,主页又被修改为了AnySearch的搜索导航界面。我发现SystemPreference(系统偏好)中多出一个profile的选项,里面能看到一个AnySearch脚本路径,然后我在profile中把脚本删除,再手动删除chrome和safari的插件,结果和第一次的尝试一样,过几天又悄无声息的装上了,主页再次被修改。我查找了很多方案都不起作用,后来发现一个比较靠谱的方法能彻底移除AnySearch的浏览器劫持,在这里记录一下。


除了删除profile中AnySearch的脚本路径和chrome浏览器以及safari浏览器的AnySearch插件以外

命令行中删除以下路径的文件

~/Library/Safari/Extensions/AnySearch.safariextz

~/Library/Saved\ Application\ State/com.apple.Safari.savedState
~/Library/Saved\ Application\ State/com.google.Chrome.savedState

2019-11-22记

仅删除文件以上无效,每过一段时间仍然会自动安装系统描述文件profile,并自动给chrome浏览器和safari浏览器安装infoSearch插件,篡改浏览器主页,拦截所有google搜索的内容到yahoo搜索,需要将~/Library/Application Support下的所有infosearch相关的文件删除。
命令行执行

1
2
cd ~/Library/Application Support
find ./ -iname "infosearch*"

找出所有相关的文件路径并删除路径下的文件


又是很长一段时间没有更新博客了,最近工作特别忙,加班多都没有时间写博客了。在如今这个快节奏的时代,难免会有这样的需求,身处办公室希望能远程下发下载任务到家里的树莓派下载资源,之前开坑玩了一下树莓派,尝试了迅雷远程下载的方案,在树莓派上搭建一个迅雷的远程下载服务器,不过目前这种方案已经失效了,迅雷关闭了远程下载的服务。不过最近我发现了一个不错的远程下载方案,aria2是一个在linux上支持远程下载的工具,无奈它的下载速度实在是太慢了,百度云的第三方下载工具PanDownload是基于aria2的,下载速度非常可观,且支持远程下载。我们可以在树莓派上搭建aria2的下载服务器,然后利用PanDownload远程下发下载任务到树莓派上的aria2服务器,即可实现远程下载。


树莓派搭建aria2服务器

关于树莓派的准备工作和系统安装这里就不讲了,希望了解的朋友可以查看上一篇,这里主要是说一下关于aria2服务器的搭建流程。

安装aria2

sudo apt install -y aria2

编写配置文件

  • 创建配置文件的文件夹
    mkdir -p ~/.config/aria2/
  • vim编写配置文件
    sudo vi ~/.config/aria2/aria2.config
    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
    #后台运行
    daemon=true
    #设置加密密钥
    rpc-secret=secret
    #允许rpc
    enable-rpc=true
    #允许所有来源, web界面跨域权限需要
    rpc-allow-origin-all=true
    #允许外部访问,false的话只监听本地端口
    rpc-listen-all=true
    #RPC端口, 仅当默认端口被占用时修改
    rpc-listen-port=6800
    #最大同时下载数(任务数), 路由建议值: 3
    max-concurrent-downloads=5
    #断点续传
    continue=true
    #同服务器连接数
    max-connection-per-server=5
    #最小文件分片大小, 下载线程数上限取决于能分出多少片, 对于小文件重要
    min-split-size=10M
    #单文件最大线程数, 路由建议值: 5
    split=10
    #下载速度限制
    max-overall-download-limit=0
    #单文件速度限制
    max-download-limit=0
    #上传速度限制
    max-overall-upload-limit=0
    #单文件速度限制
    max-upload-limit=0
    #文件保存路径
    dir=/home/pi/Downloads
    #所需时间
    file-allocation=prealloc
    #不进行证书校验
    check-certificate=false
    #保存下载会话
    save-session=/home/pi/.config/aria2/aria2.session
    input-file=/home/pi/.config/aria2/aria2.session
    #断电续传
    save-session-interval=60
  • 创建该会话空白文件
    touch /home/pi/.config/aria2/aria2.session
  • 测试下aria2是否启动成功
    aria2c --conf-path=/home/pi/.config/aria2/aria2.config
  • 是否有进程启动
    用 ps aux|grep aria2
  • 结束进程
    kill -9 xxxx

设置aria2服务并开机启动

编写服务文件

sudo vim /lib/systemd/system/aria.service

1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=Aria2 Service
After=network.target

[Service]
User=pi
Type=forking
ExecStart=/usr/bin/aria2c --conf-path=/home/pi/.config/aria2/aria2.config

[Install]
WantedBy=multi-user.target

设置开机启动

sudo systemctl daemon-reload
sudo systemctl enable aria

查看aria服务状态

sudo systemctl status aria

启动aria2服务

sudo systemctl start aria

停止aria2服务

sudo systemctl stop aria

重启aria2服务

sudo systemctl restart aria

PanDownload配置

目前PanDownload只有windows版本,mac 需开启虚拟机使用
设置 -> 远程 -> 添加 -> 添加远程主机
效果图

  1. 勾选开启远程下载模式
    效果图
  2. 填写树莓派下载服务器的ip,填写端口号对应配置文件中的rpc-listen-port和token对应配置文件中的rpc-secret
    效果图
  3. 检测连接,提示连接成功
    效果图
  4. 取消默认下载路径的配置,这样才可以在选择下载文件后弹出远程下载的选择弹窗
  5. 如果提示“下载失败:无法创建文件,给下载文件夹授权,chmod 777 /mnt/download


bilibili大名鼎鼎的ijkplayer开源播放器,有支持ffmpeg拓展,可拓展性高,体积小的优点,成为很多公司的首选播放器,但官方给的demo只支持mp4格式,想要支持横多的视频格式需要自己修改配置文件并重新编译出so文件才能实现,之前折腾了很久,一直是循环编译,最后编译失败。周末闲来下了,继续折腾终于编译成功,下面对ijkplayer的配置修改和编译做一个详细记录,方便以后查阅。

编译前提:在linux或者mac开发环境下,配置好JDK,SDK,NDK的环境变量

编译步骤:

  1. git clone ijkplayer的源码
  2. 通过init-android.sh脚本把ijkplayer的jni源码和ffmepg的源码拉下来
  3. 如果要支持https,通过init-android-openssl.sh把openssl的代码拉下来
  4. 用脚本compile-openssl.sh编译openssl
  5. 选择编译配置文件,修改配置
  6. 用脚本compile-ffmpeg.sh编译ffmpeg
  7. 用脚本compile-ijk.sh编译ijkplayer的jni源码

下面是具体的操作过程:

  1. 拉ijkplayer的源码

    git clone https://github.com/bilibili/ijkplayer

  2. 切换目录,把ijkplayer的jni源码和ffmepg的源码拉下来

    cd ijkplayer

    ./init-android.sh

  3. 把openssl的代码拉下来

    ./init-android-openssl.sh

  4. 切换目录,编译openssl

    cd android/contrib

    ./compile-openssl.sh clean

    ./compile-openssl.sh all

  5. 切换目录,修改配置文件,删除之前的配置文件软链接,并重新设置一个配置文件的软链接

    cd ../../

    cd config

    rm module.sh

    ln -s module-default.sh module.sh

    注意module-lite.sh是精简了支持格式的配置文件,module-default.sh这个是支持格式较多的配置文件

    这一步,遇到了一个错误linux/perf_event.h: No such file,当执行ffmpeg的编译操作,./compile-ffmpeg.sh all才会提示。

    解决方案:

    先删除之前的软链接文件rm module.sh

    改一下module-default.sh配置文件,在末尾添加2行代码:

    1
    2
    export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-linux-perf"
    export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-bzlib"

    再创建软链接ln -s module-default.sh module.sh

  6. 切换目录,编译ffmpeg

    cd ..

    cd android/contrib

    ./compile-ffmpeg.sh clean

    ./compile-ffmpeg.sh all

    这一步,遇到了一个错误nasm/yasm not found or too old. Use --disable-x86asm for a crippled build

    提示汇编工具没有安装。

    解决方案:

    可以安装yasm,或者按照提示执行./configure –disable-x86asm,由于yasm暂时用不上,那么就执行./configure –disable-x86asm来解决,但是这里我们编译ffmpeg是通过shell脚本,所以只能去改脚本。

    我们打开compile-ffmpeg.sh这个脚本发现它其实是在执行tools/do-compile-ffmpeg.sh这个脚本,再打开do-compile-ffmpeg.sh,搜索是否有./configure这个命令,果然在305行,我们找到了相关代码:

    1
    2
    3
    ./configure $FF_CFG_FLAGS \
    --extra-cflags="$FF_CFLAGS $FF_EXTRA_CFLAGS" \
    --extra-ldflags="$FF_DEP_LIBS $FF_EXTRA_LDFLAGS"

    这里关注一下FF_CFG_FLAGS这个变量,我们可以在它后面追加--disable-x86asm这个参数命令

    修改FF_CFG_FLAGS变量应该在调用./configure命令之前,所以我们添加一句代码在294行:

    FF_CFG_FLAGS="$FF_CFG_FLAGS --disable-x86asm",先清除之前编译的文件./compile-ffmpeg.sh clean,再编译一次./compile-ffmpeg.sh all这个错误就解决了。

  7. 切换目录,编译ijkplayer的jni源码

    cd ..

    ./compile-ijk.sh all

    编译完成,so文件一般保留arm-v7a的就可以了,路径是/ijkplayer/android/ijkplayer/ijkplayer-armv7a/src/main/libs/armeabi-v7a比较module-default.sh和module-lite.sh编译出来的so文件,前面一个明显大一些。


放假前在公司看到有同事在玩树莓派,觉得挺有意思,之前也曾经了解过,但是一直都没有下定决定买一个玩玩,这次也算是被同事拉下水吧,趁着这次放假的机会,在淘宝上买了个最新的树莓派3B+ 来学习一下,从小白开始树莓派的折腾之旅。想到自己有linux的基础应该上手不会太难,没想到还是踩了不少的坑,在购买的时候淘宝店家说会有技术支持,结果放假了完全不回消息😔,不过遇到的问题还是自己慢慢踩坑爬出来了,在这里做个简单的记录。


折腾树莓派的准备工作

贴散热贴

拿到树莓派的主板后,先贴好散热贴,总共3个散热贴,有2个带脚的散热贴和1个不带脚并有树莓标志的散热贴,主要是3个地方,CPU,GPU和内存,其中CPU和GPU在主板的正面,贴两个带脚的散热片,正中有两个近似于正方形的方块,大的是CPU,小的是GPU,而内存在主板的背面,贴不带脚并有树莓标志的散热贴。

连接风扇

树莓派的风扇主要有2根连接线,红色和黑色,红色接4针脚,黑色接6针脚,对应树莓派上的两列针脚区域,第二列的第二和第三个针脚。如果树莓派通电,接入后风扇就可以马上转起来。

安装系统

将SD卡插入读卡器,用系统烧录工具,Mac为balenaEtcher,下载好官网的debian系统的zip文件,然后烧录到SD卡中。然后把SD卡插入树莓派的SD卡槽,将树莓派接上电源,这时红色灯光常量,代表电源接通正常。树莓派读取SD卡,自动启动系统,系统启动好后绿色灯光闪烁,代表系统已经启动完毕。

接入局域网

由于mac无法插入网线直接访问树莓派,可以把树莓派的网线插入到路由器上,我们可以直接在路由器的设备管理界面看到树莓派的IP地址,接下来就可以用命令行工具ssh或者图形化界面vnc远程访问到我们的树莓派。

注意事项

作为小白,在前期的准备工作也遇到了坑😔,由于系统下错了,下成了虚拟机的树莓派镜像文件(这是个ISO文件)无语的是balenaEtcher烧录系统到SD卡的时候也没有任何报错,导致SD卡插入到SD卡槽后无法正常启动系统(然而我当时并不知道),所以接入到局域网中发现路由器的设备管理界面根本没有看到树莓派这个设备。后面看到树莓派指示灯相关的内容后,才明白原来系统都没跑起来。这里推荐使用Raspbian Stretch Lite版的系统,比较稳定,没有多余的功能,纯命令行的系统(之前安装过带图形界面的,发现不太好用,用到的也少,因为大部分功能都是命令行下实现的)。如果你发现插入SD卡只有红色灯亮,不是没有读到SD卡,就是系统启动失败,同样关机时执行关机命令后,等到绿色灯熄灭才是系统正常关闭,再拔掉电源。下面对常用的树莓派指示灯含义做一个总结:

  1. 绿色灯闪烁,读取到了SD卡并且也正常启动了系统
  2. 红色灯常亮,电源接入正常
  3. 橙色灯亮全双工状态,不亮半双工状态

    ssh连接

    我认为最简单的方法如下:
  4. 树莓派默认没有开启ssh,把sd卡用读卡器插到电脑上,创建一个名为ssh的文件
  5. 直接用网线连在家里的路由器上,通过路由器后台查看ip地址连接
    ssh pi@xxx.xxx.xxx.xxx
    默认密码为raspberry

    设置中文显示

    打开系统设置sudo raspi-config
  • Localisation Options -> Change Locale
  • 按空格选择
  • 去掉en_GB.UTF-8 UTF-8
  • 勾上en_US.UTF-8 UTF-8、zh_CN.UTF-8 UTF-8、zh_CN.GBK GBK
  • 下一屏幕默认语言选zh_CN.UTF-8

    shadowsocks配置

  1. 安装python的依赖库
    sudo apt-get install python-pip python-gevent python-m2crypto
  2. 利用python安装shadowsocks,注意别忘记sudo,否则可能造成安装路径有问题而无法使用sslocal的命令
    i
  3. 修改/usr/local/lib/python2.7/dist-packages/shadowsocks/crypto/openssl.py,将此文件中的52行和111行中的cleanup替换为reset,否则可能执行启动命令报错
  4. 创建shadowsocks的配置文件ss_conf.json,需根据shadowsocks服务器的信息替换,内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "server":"server_ip",
    "server_port":server_port,
    "local_address":"127.0.0.1",
    "local_port":1080,
    "password":"password",
    "timeout":600,
    "method":"aes-256-cfb"
    }
  5. 最后执行启动命令
    sudo sslocal -c ./ss_conf.json -d start

    privoxy配置

    在树莓派下命令行梯子是十分有必要的,给http,https设置代理使用privoxy。
  6. 安装privoxy
    sudo apt install privoxy
  7. 修改配置文件
    sudo vi /etc/privoxy/config
    783行,去掉注释
    listen-address 127.0.0.1:8118
    1336行,去掉注释,注意与ss地址端口号保持一致
    forward-socks5t / 127.0.0.1:1080
  8. 配置开机启动
    sudo vi /etc/profile
    export https_proxy=http://127.0.0.1:8118
    export http_proxy=http://127.0.0.1:8118
  9. 重启
    sudo systemctl restart privoxy

    开机启动设置

    这个步骤估计是我爬坑时间最长的地方,写入命令到rc.local配置文件中即可实现开机启动的功能,然而到树莓派上始终无法实现,树莓派已经不支持这种自启方式了。
  • 创建一个文件写入自启命令
    sudo vi /etc/rc.local
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #!/bin/sh -e
    #
    # rc.local
    #
    # This script is executed at the end of each multiuser runlevel.
    # Make sure that the script will "exit 0" on success or any other
    # value on error.
    #
    # In order to enable or disable this script just change the execution
    # bits.
    #
    # By default this script does nothing.

    # Print the IP address
    _IP=$(hostname -I) || true
    if [ "$_IP" ]; then
    printf "My IP address is %s\n" "$_IP"
    fi
    sudo sslocal -c /etc/ss.json -d start
    exit 0
  • 创建一个服务文件
    sudo vi /lib/systemd/system/rc.local.service
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #  This file is part of systemd.
    #
    # systemd is free software; you can redistribute it and/or modify it
    # under the terms of the GNU Lesser General Public License as published by
    # the Free Software Foundation; either version 2.1 of the License, or
    # (at your option) any later version.

    # This unit gets pulled automatically into multi-user.target by
    # systemd-rc-local-generator if /etc/rc.local is executable.
    [Unit]
    Description=/etc/rc.local Compatibility
    ConditionFileIsExecutable=/etc/rc.local
    After=network.target

    [Service]
    Type=forking
    ExecStart=/etc/rc.local start
    TimeoutSec=0
    RemainAfterExit=yes
    GuessMainPID=no

    修改apt源

    树莓派默认的apt源在英国,速度非常慢,即使我配置了香港的http代理,还是很慢,换成了阿里的源速度还不错。
    修改apt源的配置文件
    sudo vi /etc/apt/sources.list
    注释掉原来的源,添加
    1
    2
    deb http://mirrors.aliyun.com/raspbian/raspbian/ jessie main non-free contrib rpi
    deb-src http://mirrors.aliyun.com/raspbian/raspbian/ jessie main non-free contrib rpi
    更新索引清单
    1
    sudo apt-get update
    更新依赖库
    1
    sudo apt-get upgrade -y

    U盘挂载和卸载

    挂载u盘

    sudo mount -o umask=0000 /dev/sda1 ~/Downloads
  • o 配置挂载可选项,umask=0000对所有用户都可读可写可执行
    /dev/sda1 usb分区路径
    fdisk -l可查看分区
    ~/Downloads 要挂载的文件夹路径

    卸载u盘

    sudo umount -l ~/Downloads
  • l 强制卸载,避免busy的错误提示

    dlna服务器搭建

  1. 安装dlna
    apt-get install minidlna
  2. 修改配置文件
    sudo vi /etc/minidlna.conf
    media_dir=/home/pi/Downloads
    修改media_dir的变量指向资源路径,这里我是挂载U盘上的资源,遇到了一个坑,需要刷新资源索引,耗了很长时间,网上很多说dlna刷新命令是sudo service minidlna force-reload我发现并没有作用,以为是配置文件的问题改了很多次,还是不起作用,后来发现资源刷新的正确姿势是这样:
    sudo minidlna -R
    然后重启服务:
    sudo service minidlna restart
    设置开机启动
    sudo update-rc.d minidlna defaults
    访问http://树莓派ip地址:8200查看当前服务器上的资源类型和数量

    smaba服务器搭建

  3. 安装smaba
    sudo apt-get install samba samba-common-bin
    第二个命令也遇到过一个坑,就是smaba的部分依赖库已经安装,且版本高于smaba依赖的版本,这个时候命令行是不会安装smaba的,网上说需要把这些已经安装的依赖库卸载掉重新安装,但是我试了这种方式仍旧无效,最后我根据当前的树莓派系统版本找到了国内的镜像源(阿里),切换apt源以后再安装就可以了。
  4. 修改配置文件
    sudo vi /etc/samba/smb.conf
    1
    2
    3
    4
    5
    6
    [share]           #共享文件的名称, 将在网络上以此名称显示
    path = /home/pi/Downloads #共享文件的路径
    valid users = pi #允许访问的用户
    browseable = yes #允许浏览
    public = yes #共享开放
    writable = yes #可写
  • 创建共享文件夹
    mkdir ~/Downloads
    如果树莓派的内存卡不够用,我们一般需要挂载外置的U盘或硬盘
  • 创建挂载文件夹(挂载点)
    mkdir ~/Downloads/diskMount
  • 挂载硬盘或U盘
    sudo mount -o umask=0000 /dev/sda5 ~/Downloads/diskMount/
  • 如果是exfat格式的硬盘加上格式参数(兼容windows和mac)
    sudo mount -t exfat -o umask=0000 /dev/sda5 ~/Downloads/diskMount/
    挂载完后发现没有权限访问,需要对该文件夹下的所有文件赋予权限
    sudo chmod -R 777 ~/Downloads
    权限修改后需重启samba服务,文件即可访问
    sudo service smbd restart
    smaba4.9.5在服务启动后会在共享文件路径下生成color IA64 W32ALPHA W32MIPS W32PPC W32X86 WIN40 x64一堆文件夹,是默认配置了printer打印机引起的,这些文件夹放对应的驱动文件,如果我们注释了打印机相关的配置就不会在共享文件路径下生成这一堆文件夹
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #[printers]
    # comment = All Printers
    # browseable = no
    # path = /var/spool/samba
    # printable = yes
    # guest ok = no
    # read only = yes
    # create mask = 0700

    # Windows clients look for this share name as a source of downloadable
    # printer drivers
    #[print$]
    # comment = Printer Drivers
    # path = /var/lib/samba/printers
    # browseable = yes
    # read only = yes
    # guest ok = no
  1. 将默认用户添加到samba
    sudo smbpasswd -a pi
  2. 重启smaba
    sudo service samba restart
  3. 配置开机启动
    systemctl enable smdb

    ftp服务器搭建

    ftp方便文件传输,而且vsftpd搭建服务器只有400k
  4. 安装sftp
    sudo apt-get install vsftpd
  5. 修改配置文件
    sudo vim /etc/vsftpd.conf
    配置可选项如下:
    禁止匿名用户登录:anonymous_enable=NO
    配置用户可以写权限:write_enable=YES
    配置uMask:local_umask=022(077不支持断点续传,修改为022)
  6. 重启
    sudo service vsftpd restart

    折腾摄像头

    我在购买树莓派主板的时候,也购买了官方的摄像头,它是usb接口,插入树莓派的usb接口后,输入命令:ls /dev如果此时能识别到video0的设备时,代表树莓派已识别。
    此时我们使用lsusb命令同样可以查看这个设备的idVendor和idProduct

    拍摄照片

    拍摄一张照片
    sudo fswebcam image.jpg
    预览
    gpicview image.jpg

    搭建视频流服务器

    安装依赖库和编译库

    sudo apt install libjpeg8-dev
    sudo apt install imagemagic
    sudo apt install libv4l-dev
    sudo apt install cmake

    安装mjpg-streamer

    克隆项目到本地并编译
    sudo git clone https://github.com/jacksonliam/mjpg-streamer.git
    cd mjpg-streamer/mjpg-streamer-experimental
    make all
    sudo make install
    启动流服务器
    ./mjpg_streamer -i "./input_uvc.so" -o "./output_http.so -w ./www"
    此时报错,树莓派官方的摄像头不支持v4l的驱动,需安装UV4L兼容驱动,解决方法是
    添加软件源
    curl https://www.linux-projects.org/listing/uv4l_repo/lrkey.asc | sudo apt-key add -
    修改apt-get的源列表文件/etc/apt/sources.list,在文件末尾添加
    deb https://www.linux-projects.org/listing/uv4l_repo/raspbian/ wheezy main
    再安装兼容驱动
    sudo rpi-update
    sudo apt-get update
    sudo apt-get install uv4l uv4l-raspicam
    sudo service uv4l_raspicam restart
    sudo pkill uv4l
    sudo apt-get update
    sudo apt install uv4l-uvc
    sudo apt install uv4l-xscreen
    sudo apt install uv4l-mjpegstream
    启动流服务器
    ./mjpg_streamer -i "./input_uvc.so" -o "./output_http.so -w ./www"
    发现仍然报错
    1
    2
    Unable to set format: 1196444237 res: 640x480
    Init v4L2 failed !! exit fatal
    mjpg-stream支持JPEG和YUV两种格式,默认采用JPEG,这里是因为树莓派官方的摄像头不支持JPEG
    解决方案1:
    cd mjpg-streamer-experimental/plugins/input_uvc/
    sudo vi input_uvc.c
    format = V4L2_PIX_FMT_MJPEG改为format = V4L2_PIX_FMT_YUYV
    解决方案2:
    用命令参数-y使用YUV编码
    ./mjpg_streamer -i "./input_uvc.so -y" -o "./output_http.so -w ./www"
    此时看到启动信息已经启动,但是有一堆error
    用命令参数-n来消除error
    ./mjpg_streamer -i "./input_uvc.so -y -n" -o "./output_http.so -w ./www"

访问树莓派的ip:8080就可以看到采集的视频了


又是很久没有写博客了,最近在搞漫画爬虫项目时发现某些网站把window.scrollTo方法都屏蔽了,由于大多数漫画网站都是在网页滚动时动态加载,所以如果无法实现网页滚动是没办法爬取到所有漫画的,幸好还好有google爸爸维护的puppeteer爬虫框架,这个强大的框架当然是无视这些方法屏蔽的,它可以实现鼠标按下,滑动,弹起等一系列自动化操作,那么利用这些方法我们就可以实现网页滑动了,在此,针对原生滑动方法都被屏蔽的情况下,利用puppeteer实现网页滑动做一个记录。如果有更好的方法的小伙伴欢迎一起交流讨论。


前几天我维护代码的时候,突然发现爬虫代码报错了😂

打开该网站的控制台调试,输入window.scrollTo(0, 800);

控制台输出结果为:Uncaught TypeError: window.scrollTo is not a function

然后再打印一下window这个实例:console.log(window)

可以看到scrollTo: null

估计是网站为了反爬取而采用的措施,直接把window.scrollTo等原生方法赋值为空。

初始化

获取浏览器页面实例

1
2
let browser = await puppeteer.launch({ headless: true })
let page =await browser.newPage()

模拟PC的尺寸和UA

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 模拟pc设备mac
*/
exports.viewPort = {
width: 1080,
height: 1920
}
const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36'
/**
* 设置屏幕尺寸和UA
*/
await page.setViewport(viewPort);
await page.setUserAgent(userAgent);

实现网页滑动

利用鼠标动作组合

1
2
3
4
5
6
7
8
9
10
exports.scrollPage = async function (distance) {
await page.mouse.move(Spider.viewPort.width/2, Spider.viewPort.height * 0.9, {steps: 10})
await page.mouse.down(Spider.viewPort.width/2, Spider.viewPort.height * 0.9)
if (Spider.viewPort.height * 0.9 - distance > Spider.viewPort.height * 0.05){
await page.mouse.move(Spider.viewPort.width/2, Spider.viewPort.height * 0.9 - distance, {steps: 10})
}else{
await page.mouse.move(Spider.viewPort.width/2, Spider.viewPort.height * 0.05, {steps: 10})
}
await page.mouse.up()
}

distance是y轴滑动的垂直距离,根据屏幕尺寸计算出鼠标滑动到的组合操作的起始位置x和y的坐标点,然后模拟鼠标按下操作。因为我们需要爬取的网页,屏幕高度的5%是顶部固定的导航栏,所以结束位置的y轴坐标应该大于屏幕高度的5%。由于我们的网页滑动操作普遍是鼠标由下往上滑动,所以我们需要判断一下动作当前的起始位置的y轴坐标减去滑动的垂直距离后的y轴坐标值是否大于屏幕高度的5%,如果是,结束位置的y轴坐标为鼠标按下操作的y轴坐标减去滑动的垂直距离,否则结束位置的y轴坐标即为屏幕高度的5%。将window.scrollTo方法替换为我们自定义的scrollPage方法,传入滑动的垂直距离即可。