最近需要抓一点点微博的数据,于是又开始做爬虫的老行当了。不过这次抓的数据相对比较少,本来是觉得会很快,可惜还是因为种种原因多折腾了两个小时。这里对这部分工作做一个记录。
我个人一直觉得爬虫是个很重要的东西,尤其是针对机器学习应用的研发,好的数据、新的数据、源源不断的新的数据是好的模型的前提。甚至不用做什么机器学习模型,仅仅从好的数据发掘的一些规则都很有帮助。所以,可以快速的抓数据、快速的抓各种各样的数据是一个很重要的能力。这里就不展开讲了,还是控制篇幅,以这次任务为主。
从自己的微博首页或者随便一个主题下面抓点纯文本的微博数据,用于测试命名实体识别模型。
很久以前(大约还在读研究生的时候)那个时候的微博有一个移动版本,这个版本里面各种静态 html 非常好爬,所以难点基本就是发现 m.weibo.com
的存在...
不过很遗憾,现在不论是 weibo.com
还是 m.weibo.com
都不再是纯 html 了,都会动态获取数据了,需要有 js 的运行时环境才能以比较自然的方式抓取数据,毕竟目前 weibo 的数据动态加载策略、参数啥的有点复杂了,去解析它更麻烦。
对于动态获取数据的页面来说,可以采用 headless browwser 配合 puppeteer 来模拟页面的操作以加载并获取数据。不过我觉得这样做很明显有这么几个缺点:
针对这个问题,我觉得有两个思路可以大大缓解:
而 webscraper 则恰恰提供了以上两个东西。
webscraper 是一个 chrome 扩展,其实它的内部依然是类似与 puppeteer 的东西,不过它做成了一个扩展,并提供了一些非常强大的封装,可以让你通过点点点就能实现一个数据爬虫的流程。
封装程度高到如下的样子:
包括:
通过点点点,就能简历一个 css selector 来定位所要抓取的 html 元素路径。
在步骤 2 定位了想要抓取的具体数据后,可以在该数据上建立层级结构,标记具体想要抓取的数据结构,非常方便数据的结构化。
有关这个东西有一个 卤蛋工作室 很详细的对它做了系列教程,感兴趣的话推荐一看,我这里就简单说说。不过很遗憾,这个东西在如今的微博也不太能用了。虽然现在的微博很符合它所提供的「滚动到底部」的抓取模式,但目前的微博出于性能的考虑,对自己的这个微博列表做了元素重用,导致不论怎么滚动,最多就只有这么多个元素了。那么套用 webscraper 的「滚动到底部」就只能获取这么多个微博而已了。
Automa 也是一个 chrome 的扩展,相对于 webscraper 只用来做爬虫,automa 可以用来做各种流程自动化的东西,比 webscraper 灵活不少。除此之外,很多思路和 webscraper 一脉相承。简单看了看教程我就做出来如下一个流程:
其中的 javascript code
内容如下:
function inViewport (element) {
if (!element) return false;
if (1 !== element.nodeType) return false;
var html = document.documentElement;
var rect = element.getBoundingClientRect();
return !!rect &&
rect.bottom >= 0 &&
rect.right >= 0 &&
rect.left <= html.clientWidth &&
rect.top <= html.clientHeight;
}
setTimeout(() => {
var elements = document.querySelectorAll("article");
elements.forEach(e => {
if (inViewport(e)) {
const text = e.querySelector('.detail_wbtext_4CRf9').innerText;
console.log(text);
automaNextBlock({ text: text }, true);
}
});
}, 1000);
和 webscraper 一直滚动到满意的个数的元素后再开始解析数据不同,我做的 automa 的流程是每次滚动一点点页面,然后就获取当前视图中的微博元素并把其内容存下来。而获取当前视图的元素就是用的上面的 javascript code
了。
Automa 相对 webscraper 来说更加灵活,可以更加灵活的定制自己的抓取流程,后续可能还会有其他场景的使用吧。
最近陷入了 JPA 的泥坑,在遇到今天提及的这个问题上折腾了很久,一度怀疑人生打算重新用 MyBatis 算了。不过幸好昨天有了一些还能接受的解决方案,这里做一个记录。
标题说就是让 JPA 处理子类的关联,这里我先贴一下示例代码:
@Entity
@Inheritance
@DiscriminatorColumn(name = "type")
class Plan {
long id;
String name;
}
@Entity
@DiscriminatorValue("ComputationPlan")
class ComputationPlan extends Plan {
@ManyToOne
private Resource resource;
}
@Entity
@DiscriminatorValue("StoragePlan")
class StoragePlan extends Plan {
private long size;
}
@Entity
class Resource {
long id;
String name;
}
interface PlanRepository extends JpaRepository<Plan, Long> {
}
有一个基类 Plan
它包含两个子类 ComputationPlan
和 StoragePlan
其中 ComputationPlan
会包含额外的关联 Resource
,而 StoragePlan
则只包含一个 size
字段。Plan
采用单表继承,实现了子类的存储。而我希望通过 PlanRepository.findAll()
获取全部的 Plan
的时候可以同时获取 ComputationPlan.resource
字段而避免出现 sql n + 1 的问题。
由于 Plan
没有 resource
属性,我似乎没办法直接使用 select p from Plan p left join fetch p.resource
这样子的 JPQL 语句。JPA inheritance @EntityGraph include optional associations of subclasses stackoverflow 这个问题也做了很详细的介绍,需求基本和我是一致的。它提供了两个方案:
第一个方案,用一个 hibernate 一级缓存的 trick: 只要已经获取了同样 id 的 Entity 就不会再次从 session 中覆盖。那么这里可以先用 JPQL select p from ComputationPlan p left join fetch Resource r
获取全部的 ComputationPlan
然后再执行 JPQL select p from Plan p
获取全部 Plan,这样子原来已经在 session 缓存里的 ComputationPlan
一定已经有 resource 字段了,这个问题就解决了。可这个方式就很 tricky 了,而且如果我有很多子类怎么办?请求多次?而且如果我有分页呢?是不是就不知道要获取那些内容了呢?
第二个方案,是用一个第三方的类库 blaze persistence。这个方案也是其作者之一作答的,不过在做了简单的搜索后,且不说这个方案到底行不行,这个类库实在是有点小众,并且作为个人维护的项目,其后续的开发进展也有点堪忧,让人不太敢让公司的项目对它有什么依赖...不过其提供的答案里的 HQL 倒是给了我一些启示:
SELECT
a.id,
v.id,
TYPE(v),
CASE WHEN TYPE(v) = TextValue THEN v.text END,
CASE WHEN TYPE(v) = RatingValue THEN v.rating END,
CASE WHEN TYPE(v) = MCValue THEN s.id END
FROM Answer a
LEFT JOIN a.value v
LEFT JOIN v.selected s
其中 v 是一个 Value
类型,它也有多个子类,并且只有一个子类有 selected
属性,那么既然人家子类没有 selected
都能强行这么用,那么 Plan.resource
是不是也是可行的呢?做了个尝试:
interface PlanRepository extends JpaRepository<Plan, Long> {
@Query("select p from Plan p left join fetch p.resource")
List<Plan> findAll();
}
执行一下发现是可行的...虽然在 intellij 中 p.resource
高亮着波浪线报错了,但是还是可以编译通过。
测试下生成的 SQL 以及返回的结果,都是正确的,也就是说,其实 JPQL 也是支持这种父类 left join 子类属性的。到此为止问题就解决了。
虽然看起来方案很简单,但出于对 intellij 的信任,也是花了好久的时间才顶着这个报错去尝试的。在此之前也尝试了 EntityGraph
中的 subClassSubGraph
但感觉这个东西应该是有 bug 并不能成功:
@NamedEntityGraph(
name = "all",
attributeNodes = {
@NamedAttributeNode("id"),
@NamedAttributeNode("name")
},
subgraphs = {
@NamedSubgraph(
name = "resource",
type = Resource.class,
attributeNodes = {
@NamedAttributeNode("id"),
@NamedAttributeNode("name"),
}
)
},
subclassSubgraphs = {
@NamedSubgraph(
name = "computePlan",
type = ComputePlan.class,
attributeNodes = {
@NamedAttributeNode(value = "resource", subgraph = "resource")
}
)
}
)
class Plan {
...
}
使用上述的 NamedEntityGraph
依然会出现 LazyInitializationException
的问题,并且其生成的 SQL
语句也明显没有对 Resource
资源进行 left join
操作。
虽然一直非常喜欢 mybatis 但从效率的角度来看,hibernate 也是一个值得尝试的工具。最近开始做一些尝试,看看是不是的利用 jpa 来进一步的简化目前很多繁琐无聊的 sql 的编写。这里记录下今天遇到的有关自定义类型的一个知识点。
在 mybatis 里有 type handler 的概念,用于实现表结构和自定义的 java 对象之间的转换。而 hibernate 也有一个相似的概念,不过这里我只是记录下目前这种一个对象对应多个 column 的场景。
joda money 是一个轻量级的货币类型,可以用来存储货币,比如人民币,美元,日元等。它有两个关键字段,一个是 amount,一个是 currency。那么很自然的就希望在数据库里对应两个 column 分别是 decimal 类型的 amount 和 varchar 类型的 currency。
我在 这篇文章 找到了自己想要的东西。就是要实现一个 JodaMoneyType
实现 joda money
类型和数据库中两个列的映射。
public class JodaMoneyType implements UserType {
@Override
public int[] sqlTypes() {
return new int[] {Types.VARCHAR, Types.DECIMAL}; // [1]
}
@Override
public Class returnedClass() {
return Money.class;
}
@Override
public boolean equals(Object x, Object y) throws HibernateException {
if (x == y) { return true; }
if (x == null || y == null) { return false; }
return x.equals(y);
}
@Override
public int hashCode(Object x) throws HibernateException {
if (x == null) { return 0; }
return x.hashCode();
}
@Override
public Object nullSafeGet(ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) throws HibernateException, SQLException {
if (rs.wasNull()) // [2]
return null;
String currency = rs.getString(names[0]);
BigDecimal amount = rs.getBigDecimal(names[1]);
return Money.of(CurrencyUnit.of(currency), amount);
}
@Override
public void nullSafeSet(PreparedStatement st, Object value, int index, SharedSessionContractImplementor session) throws HibernateException, SQLException {
if (Objects.isNull(value)) { // [3]
st.setNull(index, Types.VARCHAR);
st.setNull(index + 1, Types.DECIMAL);
} else {
Money money = (Money) value;
st.setString(index,money.getCurrencyUnit().getCode());
st.setBigDecimal(index+1,money.getAmount());
}
}
@Override
public Object deepCopy(Object value) throws HibernateException {
if (value == null) { return null; }
Money money = (Money) value;
return Money.of(money.getCurrencyUnit(), money.getAmount());
}
@Override
public boolean isMutable() { // [4]
return false;
}
@Override
public Serializable disassemble(Object value) throws HibernateException {
return (Serializable) value; // [5]
}
@Override
public Object assemble(Serializable cached, Object owner) throws HibernateException {
return cached; // [6]
}
@Override
public Object replace(Object original, Object target, Object owner) throws HibernateException {
return original; // [7]
}
}
按照注释标记做一些解释:
然后使用的时候按照如下添加注解即可:
public class Deposit {
...
@Columns(columns = {@Column(name = "currency"), @Column(name = "amount")})
@Type(type = "com.example.learnjpa.deposit.JodaMoneyType")
private Money paidMoney;
...
}
hibernate 可以通过设置日志级别以显示丰富的信息。通常在测试和开发过程中会在 application.properties
设置如下:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
以上设置分别展示 sql 语句,对 sql 语句做一定个格式化,以及展示每一个 sql 语句的参数。但是在使用 UserType 的时候会出现如下的奇怪问题:
Hibernate:
update
openbayes_deposits
set
currency=?,
amount=?,
owner_id=?,
price=?
where
id=?
2022-03-14 23:16:50.548 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [aisensiy]
2022-03-14 23:16:50.548 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [INTEGER] - [111]
2022-03-14 23:16:50.548 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [5] as [VARCHAR] - [13123]
明明是 5 个参数,前两个 currency 和 amount 对应的 paramter 居然没有打印出来。这个行为甚让我一开始以为自己写的 UserType 是有问题的。
做了进一步了解后才了解到 hibernate 有 TypeDescriptor 的概念,而这里展示的参数也需要对应的 TypeDescriptor 支持才能展示出来。
@Override
public void nullSafeSet(PreparedStatement st, Object value, int index, SharedSessionContractImplementor session) throws HibernateException, SQLException {
if (Objects.isNull(value)) {
st.setNull(index, Types.VARCHAR);
st.setNull(index + 1, Types.DECIMAL);
} else {
Money money = (Money) value;
st.setString(index,money.getCurrencyUnit().getCode());
st.setBigDecimal(index+1,money.getAmount());
+ session.remapSqlTypeDescriptor(new VarcharTypeDescriptor())
+ .getBinder(StringTypeDescriptor.INSTANCE)
+ .bind(st, ((Money) value).getCurrencyUnit().getCode(), index, session);
+ session.remapSqlTypeDescriptor(new DecimalTypeDescriptor())
+ .getBinder(BigDecimalTypeDescriptor.INSTANCE)
+ .bind(st, ((Money) value).getAmount(), index + 1, session);
}
}
通过如上代码可以添加 TypeDescriptor 支持,这样就可以展示出来了。
Hibernate:
update
openbayes_deposits
set
currency=?,
amount=?,
owner_id=?,
price=?
where
id=?
2022-03-14 23:16:50.547 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [USD]
2022-03-14 23:16:50.547 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [DECIMAL] - [222.00]
2022-03-14 23:16:50.548 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [aisensiy]
2022-03-14 23:16:50.548 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [4] as [INTEGER] - [111]
2022-03-14 23:16:50.548 TRACE 71555 --- [ Test worker] o.h.type.descriptor.sql.BasicBinder : binding parameter [5] as [VARCHAR] - [13123]