技术手记

人人网备份工具终于算个活跃的 GitHub 项目了

人人网社交资产真的卖给了多牛所致,renrenBackup 这个项目在 GitHub 上很快多了好多 star(也是自己跑到人人和 v2ex 上安利了一波),也有人提 issue 和 PR,这样也挺好,项目有人用有人吐槽,才算一个合适的项目

开心的是

  1. 自己做的东西有人用,能帮到人,这是做技术的人最开心的吧
  2. 有更多奇怪的边界条件被挖掘到,从而增强项目健壮性
  3. 有人帮忙提 PR,主动帮忙处理边界条件

好气也好笑的有

  1. 各种无脑提需求或试图引入各种第三方库来实现某些功能的,我都各种努力把 BeautifulSoup 和 pyquery 给从依赖去掉,你还要我加回去?目标还是能尽可能的简单的运行起来,我还想做 .exe 的版本给 Windows 用呢
  2. 人人改过登录逻辑,不用每次都取加密 key 了,在撞上验证码时无法正常工作。修复前还跟人争了半天,不过之前那个取验证码的逻辑也写的有问题,多取了一次
  3. 真的是各种意想不到的脏数据
    • 评论里有用户已经销号,有字段缺值,返回的 json 都是不合法的
    • 相册加密,能看到里面的张数但是打不开
    • 抓别人的留言板遇上留言板不公开,第一页都打不开

比较不高兴的有

  1. 提问题像大爷一般,好像欠着他什么一样的口气,我做这个又没收你钱,做着玩的东西,大家有问题一起好好分析,能解决去解决,不能解决把问题复现步骤更明确的给出来让有能力的人去解决就好,谁也不欠谁

补遗: 机器学习手记系列 3 线性回归样例程序

距离 2012 的两三年后(这篇的草稿时间)又过了两三年,这个补遗看起来也烂尾了 -.-

之前在机器学习手记系列 3: 线性回归和最小二乘法后面留了个问题, 也给了结果, 但是当时说好的程序代码并没给出来, 那个手记系列的坑感觉填不上了, 但是已经刨好的小坑还是填上吧

现在已经有很多深度学习框架和教程来教这个,自己也忘得差不多了,就不班门弄斧裸写。推荐看一下 动手学深度学习 http://zh.gluon.ai/index.html,Deep Learning 领域大神 李沐 等人在维护(我能凑不要脸的蹭热度说下这是前百度同事我们还一起吃饭打牌来着么)。刨的小坑就按 线性回归的从零开始实现 http://zh.gluon.ai/chapter_deep-learning-basics/linear-regression-scratch.html 里的做法来实现

先重复下问题

如下式子里不同的阿拉伯数字只是一个符号, 实际表示的可能是其他数字
967621 = 3
797321 = 1
378581 = 4
422151 = 0
535951 = 1
335771 = 0

根据上述式子, 判断下式等于?
565441 = ?

这题的脑筋急转弯版本答案是看每个数字有几个圈,就代表几,这样 1/2/3/4/5/7 都是 0 个圈,6/9 是 1 个圈,8 是 2 个圈,所以最后 565441 里面只有 6 有 1 个圈,答案为 1

按 gluon 上的教程我们也来走一遍,装环境什么的就看 gluon 了,先引入要用的包

from mxnet import autograd, nd

真正做线性回归是没法只用这么一点数据来模拟的,所以我们要先根据真实值来构造一些数据(这里跟 gluon 不一样的是我没有 bias 因子 b,后面也请一并注意)

num_inputs = 9          # 特征数,当前问题里的变量数 1-9
num_examples = 1000     # 样例数,我们会随机生成多少份样例来学习
true_w = nd.array([0, 0, 0, 0, 0, 1, 0, 2, 1])  # 真实值
features = nd.random.normal(scale=1, shape=(num_examples, num_inputs))  # 随机生成数据集
labels = nd.dot(features, true_w)                                   # 数据集对应的结果

初始化模型参数并创建梯度

w = nd.random.normal(scale=0.01, shape=(9, 1))
w.attach_grad()

定义模型,我们就是做的矩阵乘法

def linreg(X, w):
    return nd.dot(X, w)

定义损失函数,用平方损失

def squared_loss(y_hat, y):
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

定义优化算法,用小批量随机梯度下降(因为我们只用了一个大参数 w,所以还是比 gluon 的样例简单)

def sgd(param, lr, batch_size):
    param[:] = param - lr * param.grad / batch_size

训练,取步长 lr 为 0.01,轮次为 1000 轮

def train():
    lr = 0.01
    num_epochs = 1000
    net = linreg
    loss = squared_loss

    for epoch in range(num_epochs):
        with autograd.record():
            l = loss(net(features, w), labels)
        l.backward()
        sgd(w, lr, labels.size)
        train_l = loss(net(features, w), labels)
        if epoch % 100 == 99:
            print("epoch {}, loss {}, w {}".format(epoch + 1, train_l.mean().asnumpy(), w))

验证下结果看看

if __name__ == "__main__":
    train()
    test = nd.array([1, 0, 0, 2, 2, 1, 0, 0, 0])    # 测试集,565441
    print(nd.dot(test, w))

随便跑了一次输出如下,注意模型里每个值的科学计数法的指数

epoch 1000, loss [  5.72006487e-09], w
[[ -6.20802666e-06]
 [  1.62000088e-05]
 [ -1.03610901e-05]
 [  7.82768348e-06]
 [  2.59973749e-05]
 [  9.99964714e-01]
 [  1.86312645e-05]
 [  1.99990368e+00]
 [  1.00001490e+00]]
<NDArray 9x1 @cpu(0)>

[ 1.00002611]
<NDArray 1 @cpu(0)>

忽略精度问题,可以认为符合真实结果

全部代码详见 https://gist.github.com/whusnoopy/af0aa6fd276ace8a7c4d483e586e936d

Python decorator 库

上个月关于 Python 的 Decorator 写过一篇 Python 多层 decorator 内获取原始函数参数字典,后来熊提醒这种比较通用的东西应该都会有现成的库,搜了下果然有 个库就叫 Decorator

相关的项目地址在

看了下源码,他比之前我的做法更进一步,直接把要修饰的原方法的名字和参数,都扫代码解析出来,再到一个解析器的临时文件里原样写一个新的方法,并把相关需要复制的参数属性等都直接复制给这个新方法,这个新方法再调修饰方法,就可以更完美的实现对外界透明

例如这样的代码

def foo(x, y=2, *args, **kw):
  print(x)

def dec(func):
  def wrapper(*args, **kw):
    print("called %s with %s, %s" % (func.__name__, args, kw))
    return func(*args, **kw)
  return wrapper

foo = dec(foo)

在经过了 @decorator 后会变成

_func = foo
_caller = wrapper

foo = def foo(x, y=2, *args, **kw):
  return _caller(_func)(x, y, *args, **kw)

只有一些涉及到 func_code 内存地址的地方才可以发现不一样

果然还是有库用库,绝大部分情况人家还是实现得更好,就是原理能不能看懂了。自己折腾的好处是正向推过去,坑和原理都比较了解,而反向看别人的代码,有很多奇妙的地方先想明白为什么要考虑这个情况,以及这个情况为什么要这样处理,都需要花很久

最后小吐槽一下,在 decorator 库里,如果是 py3,replace('return', 'return await') 真的没问题?如果有人逗比写了个 returnVal = xxx 不就崩了

MongoDB 升级注意事项

最近在 WSL 和 macOS 下都遇到了 MongoDB 升级时报错的问题,记录一下踩的坑

1. 确认已经兼容新的版本

参考 https://docs.mongodb.com/manual/release-notes/4.0/#feature-compatibility 看一下当前设置的是多少

db.adminCommand( { getParameter: 1, featureCompatibilityVersion: 1 } )

如果还不够新版本的,参考 https://docs.mongodb.com/manual/reference/command/setFeatureCompatibilityVersion/ 设置一下

db.adminCommand( { setFeatureCompatibilityVersion: "version" } )

注意版本跳太多了可能会失败,则需要启一个老版本把版本一步一步设上去(我的 WSL 就是从 2.6 跳 3.6 直接失败了,想 apt 退回去都不行,只能人肉下了个单独运行的 3.2 还是 3.4 做跳板,设完了才放弃)

2. 确认已经使用新的 YAML 格式配置文件

参考 https://docs.mongodb.com/manual/reference/configuration-options/,配置文件 Linux 一般是在 /etc/mongod.conf,macOS 如果是 brew 装的,一般是在 /usr/local/etc/mongod.conf

如果不是 YAML 的可以参考 https://github.com/mongodb/mongo/blob/master/debian/mongod.conf 新写一个,对着原来的文件把相关参数改一下就好(主要是 dbPathsystemLog.path,还有就是 processManagement 下要不要加 fork: true

3. 改用 wiredTiger 引擎

从 4.x 开始 MongoDB 就要放弃 mmapv1 引擎,尽快改成 wiredTiger

参考 https://docs.mongodb.com/manual/tutorial/change-standalone-wiredtiger/ 来做改动,大致步骤是

  1. 先在当前启动的 mongo 下做 mongodump,备份已有数据
  2. 停掉当前 mongo(注意 macOS 下 brew services stop 可能没有真的停,再 ps -el | grep mongo 看看还有没有进程)
  3. 修改配置文件,把 storage.engine 的注释去掉并改为 engine: wiredTiger
  4. 移除以前的 dbPath 下的所有文件(安全起见可以 mv 走而不是 rm -rf
  5. 按新配置文件启动 mongo
  6. mongorestore 来恢复之前的备份

做了个人人网的备份工具

总感觉哪天人人可能就不运营了,趁还能抓,先把能抓的抓到本地来,那些不管是牛逼还是傻逼抑或二逼的的过往,留着吧,偶尔看看也挺有意思的

项目在 GitHub 上:https://github.com/whusnoopy/renrenBackup,有问题可以在这里留言,或直接在 GitHub 上发 Issue 或 Pull Request

抓了状态、留言、相册和日志,以及对应的评论、点赞

其中点赞只有总数和最近的 8 个人的名单,受限没找到拿全量的接口,只能这样,翻状态发现 2014 年的时候就吐槽过只能看 8 个人点赞,当时还说有改版计划会看到全部,后来随着人人慢慢没落转型,应该也没人提这事了

评论看起来是人人本身就丢了一些,或者奇怪的隐私策略或怎样,总感觉漏掉一点,不过也尽力把人人按 API 给的对应评论和全站评论都保存了下来

状态应该漏掉早期的一部分,我只能抓到 2008 年左右的,更早的忘了是没有状态这个产品,还是就是数据丢了。状态有些是带图或带地理信息的,这部分都没抓,通过对于的 API 似乎也没拿到这些信息

分享的类型太杂,没有 json 接口,裸解析页面太伤了,暂时不打算做,后期如果有人一起或想起来再说

人人的图片大部分不允许跨域调,索性也爬到本地来,主要是照片和头像,然后照片的失真度比较大,有 EXIF 信息什么的也懒得爬了,毕竟这些不是重点

我的数据量应该只算一般,爬起来还没太大问题,那些量大类杂的,可能还会遇到新的坑,只能遇坑填坑

VS Code 里 Python 扩展提示 Path 有非法字符

最近在 Win10 下打开 VS Code 时总会遇到这个报错

VS Code 启动时 Python 扩展提示 Path 环境变量有错误

搜了一圈发现还是官方的锅,详见 https://github.com/Microsoft/vscode-python/issues/2076

看了下 GitHub 上的 issue 和 Stack Overflow 上的讨论,理解了下造成这个问题的大概原因

  1. VS Code 里 Python 扩展会检查 Windows 的 Path 环境变量并解析,如果有预期之外的分号 ';' 双引号 '"' 或连续分号 ';;' 则报错
  2. Windows 的 Path 很可能是由若干个变量组成的,比如 Path=%Path%;C:\Python27\;%LOCAL_PATH%
  3. 有的变量为了自我严谨,最后是加了分号的,有的环境变量在引用别人时,怕别人最后没加分号,就在引用后立马接上一个分号
  4. 拼起来就呵呵了

看起来官方已经知晓并明确问题,只能等下一个版本更新去掉这个严格检查,或看他们有什么更好的处理方法

Python 多层 decorator 内获取原始函数参数字典

0. 在 decorator 里获取原始函数的参数值

项目里做了一个通用锁,使用 decorator 来方便的包住某些需要限制并发的函数。因为并发不是函数级别的,而是根据参数来限制,所以需要把参数传到通用锁的 decorator 里,代码大致如下

def lock_decorator(key=None):
    def _lock_func(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # TODO: get lock_key
            lock_key = kwargs.get(key, '')
            with LockContext(key=lock_key):
                return func(*args, **kwargs)
        return wrapper
    return _lock_func

@lock_decorator(key='uid')
def apply_recharge(uid, amount):
    # ...

考虑到函数调用不一定都是带着参数名的,就是说调用时不一定所有参数都会进 **kwargs,那就需要从 **args 里面按参数名捞参数

怎么能知道原函数的参数名列表,翻各种手册的得知可以用 inspect.getargspec(func) 来搞到,那么上面的 TODO 部分就可以改写如下

            args_name = inspect.getargspec(func)[0]
            key_index = args_name.index(key)
            if len(args) > key_index:
                lock_key = args[key_index]
            else:
                lock_key = kwargs.get(key, '')

自此,一切都很美好

1. 在 decorator 里获取原始函数的调用参数字典

项目里又做了个通用的 Logger,也做成 decorator 往目标函数一套,就可以打印出调用时的入参和结果,大致如下

def log_decorator():
    def _log_func(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # TODO: get full args
            print('call func [{}] with args [{}] and kwargs [{}]'.format(func.__name__, args, kwargs))
            ret = func(*args, **kwargs)
            print('func [{}] return [{}]'.format(func.__name__, ret))
            return ret
        return wrapper
    return _log_func

@log_decorator()
def apply_recharge(uid, amount):
    # ...

看起来也还好,不过因为函数可能带默认参数,而且也希望看到 **args 到底传到哪个参数上,还是希望把所有参数按 Key-Value 的形式打印出来,跟处理通用锁一样,用 inspect.getargspec(func) 把参数名和默认值都摸出来,再考虑一下可变参数的情况,对上面的 TODO 部分改写如下

            args_name, _, _, func_defaults = inspect.getargspec(func)
            parsed_kwargs = dict()
            # default args
            default_args = dict()
            default_start = len(args_name, func_defaults)
            for idx, d in enumerate(func_defaults):
                default_args[args_name[default_start + idx]] = d
            parsed_kwargs.update(default_args)
            # args with name
            varargs_start = len(args_name)
            for idx, a in enumerate(args[:varargs_start]):
                parsed_kwargs[args_name[idx]] = a
            # varargs
            if len(args) > varargs_start:
                parsed_kwargs['varargs'] = args[varargs_start:]
            # kwargs
            parsed_kwargs.update(kwargs)
            print('call func [{}] with args [{}]'.format(func.__name__, parsed_kwargs))

到这里,还是很美好

2. 多层 decorator 怎么拿到最原始函数的参数表

注意到上面两个例子里,apply_recharge 都只套了一个 decorator,如果两个一起用会发生什么?

根据 PEP318 里对 decorator 的定义

@dec2
@dec1
def func(arg1, arg2, ...):
    pass

等价于

def func(arg1, arg2, ...):
    pass
func = dec2(dec1(func))

这里就出问题了,dec2 拿到的传入函数其实是 dec1 而不是 func。不过在把 lock_decoratorlog_decorator 混用时,不管谁写前面,func.__name__ 都是原始的函数名,说明也还是有神器的地方做了穿透,但是 inspect.getargspec 又拿不到最底层函数的参数表,导致不管谁前谁后,都有问题

注意到每个 decorator 构建的时候都又封了一个 @functools.wraps(func),这个是干嘛的呢?以前都是无脑用,也没想过为啥要包一层这个,去掉会怎样?

去掉这个 @functools.wraps(func) 后,inspect.getargspec 还是一样的只能拿到最近一层的信息,而之前本来可以拿到底层的 func.__name__ 也变成最近一层的函数名了,说明这里做了穿透。那么去看看代码吧

# functools.py

from _functools import partial, reduce

# update_wrapper() and wraps() are tools to help write
# wrapper functions that can handle naive introspection

WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__doc__')
WRAPPER_UPDATES = ('__dict__',)
def update_wrapper(wrapper,
                   wrapped,
                   assigned = WRAPPER_ASSIGNMENTS,
                   updated = WRAPPER_UPDATES):
    """Update a wrapper function to look like the wrapped function

       wrapper is the function to be updated
       wrapped is the original function
       assigned is a tuple naming the attributes assigned directly
       from the wrapped function to the wrapper function (defaults to
       functools.WRAPPER_ASSIGNMENTS)
       updated is a tuple naming the attributes of the wrapper that
       are updated with the corresponding attribute from the wrapped
       function (defaults to functools.WRAPPER_UPDATES)
    """
    for attr in assigned:
        setattr(wrapper, attr, getattr(wrapped, attr))
    for attr in updated:
        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
    # Return the wrapper so this can be used as a decorator via partial()
    return wrapper

def wraps(wrapped,
          assigned = WRAPPER_ASSIGNMENTS,
          updated = WRAPPER_UPDATES):
    """Decorator factory to apply update_wrapper() to a wrapper function

       Returns a decorator that invokes update_wrapper() with the decorated
       function as the wrapper argument and the arguments to wraps() as the
       remaining arguments. Default arguments are as for update_wrapper().
       This is a convenience function to simplify applying partial() to
       update_wrapper().
    """
    return partial(update_wrapper, wrapped=wrapped,
                   assigned=assigned, updated=updated)

原来就是这里耍花样了,把底层函数的 ('__module__', '__name__', '__doc__') 都赋给了 decorator 封起来的这一层,欺骗更上层用 __name__ 去判断时就当我是底层

那我也学这个,把 inspect.getargspec 的地方也处理下不就完了,去看看这个地方是怎么拿参数表的

# inspect.py

def getargspec(func):
    """Get the names and default values of a function's arguments.

    A tuple of four things is returned: (args, varargs, varkw, defaults).
    'args' is a list of the argument names (it may contain nested lists).
    'varargs' and 'varkw' are the names of the * and ** arguments or None.
    'defaults' is an n-tuple of the default values of the last n arguments.
    """

    if ismethod(func):
        func = func.im_func
    if not isfunction(func):
        raise TypeError('{!r} is not a Python function'.format(func))
    args, varargs, varkw = getargs(func.func_code)
    return ArgSpec(args, varargs, varkw, func.func_defaults)

看了下用到了 func.func_codefunc.func_defaults,按 Python 官方文档 https://docs.python.org/2/library/inspect.html 的解释,func_code 是运行时的字节码,从这里面捞参数表果然可行,那是不是我把这两个属性也传递上去就行了呢?改用自己的 wraps 如下

WRAPPER_ASSIGNMENTS = functools.WRAPPER_ASSGNMENTS + ('func_code', 'func_defaults')
def my_wraps(wrapped,
             assigned = WRAPPER_ASSIGNMENTS,
             updated = WRAPPER_UPDATES):
    return _functool.partial(functools.update_wrapper, wrapped=wrapped,
                             assigned=assigned, updated=updated)

运行时报错,看了下错误提示,func_code 不可覆盖,这也对,都是运行时的字节码了,这个覆盖掉那包的这层 decorator 到底还有没有自己的逻辑部分

还是自己动手丰衣足食,既然 func_code 不可覆盖,我自己另外弄一个总可以了吧,而且当前需求是拿到参数表和默认参数,那就直接解出来穿透,也懒得最后再解一次。修改 my_wraps 如下

WRAPPER_ASSIGNMENTS = functools.WRAPPER_ASSIGNMENTS + ('__func_args_name__', '__func_default_args__')

def my_wraps(wrapped,
             assigned = WRAPPER_ASSIGNMENTS,
             updated = functools.WRAPPER_UPDATES):
    if getattr(wrapped, '__func_args_name__', None) is None:
        setattr(wrapped, '__func_args_name__', inspect.getargs(wrapped.func_code)[0])
        func_defaults = getattr(wrapped, 'func_defaults') or ()
        default_args = dict()
        default_start = len(wrapped.__func_args_name__) - len(func_defaults)
        for idx, d in enumerate(func_defaults):
            default_args[wrapped.__func_args_name__[default_start + idx]] = d
        setattr(wrapped, '__func_default_args__', default_args)
    return _functools.partial(functools.update_wrapper, wrapped=wrapped,
                              assigned=assigned, updated=updated)

同时在运行时解参数表,也用一个通用函数来实现

def parse_func(func, *args, **kwargs):
    parsed_kwargs = dict()
    # default args
    parsed_kwargs.update(func.__func_default_args__)
    # args with name
    varargs_start = len(func.__func_args_name__)
    for idx, a in enumerate(args[:varargs_start]):
        parsed_kwargs[func.__func_args_name__[idx]] = a
    # varargs
    if len(args) > varargs_start:
        parsed_kwargs['varargs'] = args[varargs_start:]
    # kwargs
    parsed_kwargs.update(kwargs)

    return parsed_kwargs

这样在 lock_decoratorlog_decorator 里,用 my_wraps 来封装处理,同时在里面用 parse_func 来解析参数,就能拿到完整的参数表了

完整的测试代码见 https://gist.github.com/whusnoopy/9081544f7eaf4e9ceeaa9eba46ff28da

cmder 在 Win10 WSL 下粘贴丢字符的解决

Win10 升级到 1803 后,用 cmder 连的 bash,粘贴文本时总丢大量字符,怀疑某些部分被识别成了控制字符或怎样。一开始以为是 WSL 的问题,不过后面交叉验证,发现如果用 cmder 开 Windows 命令行,或直接在 Windows 命令行下运行 bash 就没事。翻了好久,终于在 ConEmu 项目下找到相关讨论,作者最近认可了这个问题并发布了更新:https://github.com/Maximus5/ConEmu/issues/1545#issuecomment-386444227

因为 cmder 就是封装了一下 ConEmu,所以去 https://github.com/Maximus5/ConEmu/releases/tag/v18.05.06 下载最新的 180506 版本 ConEmu,并解压到 cmder 目录的 cmder\vendor\conemu-maximus5 下替换原来的文件就好

WSL 下一些奇怪的路径依赖问题优化

在公司换用 Windows 做开发机,装了 Windows Subsystem for Linux(WSL),也就是那个 Ubuntu,用来跑开发环境

我的代码放在 Windows 的文件系统里,在 WSL 里通过 ln -s /mnt/c/foo ~/foo 的方式映射过去,不过在跑 yarn 装 node modules 的时候,会经常出现路径依赖的错误,大概就是 /mnt/c/xxxxx 这样的路径在计算父目录或子目录时会出问题

另外我跑 Docker,是使用 Docker for Windows 作为宿主,在 WSL 里装 Linux 的 Docker 客户端做控制,跑 docker-compose 总是发现挂载不上开发目录到文件系统,最后看了下是 WSL 默认的 /mnt/c/ 这样的挂载点识别有问题

最后按某些野路子方法,把 WSL 访问宿主机的入口调整为 /c/ 这样就好了

$ sudo mkdir /c
$ sudo mount --bind /mnt/c /c

不过这有个问题是重启后需要重新挂载,之前有按别的一些处理方式写到 /etc/fstab 文件表里,但是 WSL 不支持自动加载,所以按 https://nickjanetakis.com/blog/setting-up-docker-for-windows-and-wsl-to-work-flawlessly 的提示来加到 ~/.bashrc 里或我的 ~/.zshrc 里,并把 /bin/mount 改成所有用户都可启用

$ echo "sudo mount --bind /mnt/c /c" >> ~/.bashrc && source ~/.bashrc
$ sudo echo "yourname ALL=(root) NOPASSWD: /bin/mount" >> /etc/sudoers

注 1:yarn 的问题似乎现在在 /mnt/c/foo 这样的目录结构下工作正常了,不确定是不是 yarn 升级处理了这个问题

隐藏 HP 打印机在 Win10 下无法安装的驱动更新

公司使用的打印机是 HP 的 1536,很经典的一款,但是也有一个很头疼的问题,就是在 Windows 10 下,会总是提示会有驱动升级,名称是 HP driver update for HP LaserJet M1530 MFP Series PCL 6,但是从来都无法安装成功

看这个事情很不爽,但是一直也没有找到很好的解决办法,HP 官方论坛和微软的官方论坛里都没有合理的解决,有提议把驱动卸了就不会出更新提示的,但是,卸载了驱动我还怎么打印

好在今天搜到一个 Win10 系统禁止某一更新 的方法,实测解决,原文请点左边的链接进去,我这里简单重复

  1. 去微软官网下载 wushowhide.diagcab
  2. 运行,在 Hide Update 里勾选不想要的更新,解决