├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── image ├── search_1.png ├── search_2.png ├── search_3.png └── search_4.png ├── settings.gradle.kts └── src ├── main ├── kotlin │ └── xyz │ │ └── cssxsh │ │ └── mirai │ │ └── pixiv │ │ ├── PixivCacheLoader.kt │ │ ├── PixivClientPool.kt │ │ ├── PixivConfig.kt │ │ ├── PixivEventListener.kt │ │ ├── PixivHelper.kt │ │ ├── PixivHelperDelegate.kt │ │ ├── PixivHelperDownloader.kt │ │ ├── PixivHelperGifEncoder.kt │ │ ├── PixivHelperMP4Encoder.kt │ │ ├── PixivHelperPlugin.kt │ │ ├── PixivHelperPool.kt │ │ ├── PixivProperty.kt │ │ ├── PixivScheduler.kt │ │ ├── PixivSkikoGifEncoder.kt │ │ ├── PixivUtils.kt │ │ ├── command │ │ ├── ArknightsEroCommand.kt │ │ ├── PixivBoomCommand.kt │ │ ├── PixivCacheCommand.kt │ │ ├── PixivCommandArgumentContext.kt │ │ ├── PixivDeleteCommand.kt │ │ ├── PixivEroCommand.kt │ │ ├── PixivGetCommand.kt │ │ ├── PixivHelperCommand.kt │ │ ├── PixivInfoCommand.kt │ │ ├── PixivMethodCommand.kt │ │ ├── PixivSearchCommand.kt │ │ ├── PixivSettingCommand.kt │ │ ├── PixivTagCommand.kt │ │ └── PixivTaskCommand.kt │ │ ├── data │ │ ├── EditThisCookie.kt │ │ ├── EroStandardConfig.kt │ │ ├── EroStandardData.kt │ │ ├── ImageSearchConfig.kt │ │ ├── PixivAuthData.kt │ │ ├── PixivConfigData.kt │ │ ├── PixivGifConfig.kt │ │ ├── PixivHelperSettings.kt │ │ ├── PixivTaskData.kt │ │ ├── RegexSerializer.kt │ │ └── SendModel.kt │ │ ├── event │ │ └── PixivEvent.kt │ │ ├── model │ │ ├── AliasSetting.kt │ │ ├── ArtWorkInfo.kt │ │ ├── ArtWorkTag.kt │ │ ├── Author.kt │ │ ├── FileIndex.kt │ │ ├── FileInfo.kt │ │ ├── ImageSearch.kt │ │ ├── NaviRank.kt │ │ ├── PixivArticle.kt │ │ ├── PixivEntity.kt │ │ ├── PixivHibernateConfiguration.kt │ │ ├── PixivSqlLoad.kt │ │ ├── SimpleArtworkInfo.kt │ │ ├── StatisticEroInfo.kt │ │ ├── StatisticTagInfo.kt │ │ ├── StatisticTaskInfo.kt │ │ ├── StatisticUserInfo.kt │ │ ├── TagRecord.kt │ │ ├── Twitter.kt │ │ └── UserBaseInfo.kt │ │ ├── task │ │ ├── DataCron.kt │ │ ├── DurationSerializer.kt │ │ ├── Parser.kt │ │ ├── PixivLoad.kt │ │ └── PixivTimerTask.kt │ │ └── tools │ │ ├── HtmlParser.kt │ │ ├── ImageSearcher.kt │ │ ├── NaviRank.kt │ │ └── Pixivision.kt └── resources │ ├── META-INF │ └── services │ │ └── net.mamoe.mirai.console.plugin.jvm.JvmPlugin │ └── xyz │ └── cssxsh │ └── mirai │ └── pixiv │ └── model │ ├── create.h2.sql │ ├── create.mysql.sql │ ├── create.postgresql.sql │ ├── create.sqlite.sql │ └── create.sqlserver.sql └── test └── kotlin └── xyz └── cssxsh └── mirai └── pixiv └── tools ├── ImageSearcherTest.kt └── NaviRankTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | **/.gradle/ 3 | **/build/ 4 | 5 | # idea 6 | /.idea/ 7 | 8 | # mirai 9 | /data/ 10 | /plugins/ 11 | /logs/ 12 | /test/ 13 | /run/ 14 | 15 | # temp 16 | /temp/ 17 | 18 | *.sqlite 19 | hibernate.properties 20 | /debug-sandbox -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Pixiv Helper](https://github.com/cssxsh/pixiv-helper) 2 | 3 | > 基于 [Mirai Console](https://github.com/mamoe/mirai-console) 的 [Pixiv](https://www.pixiv.net/) 插件 4 | 5 | 基于 Kotlin Pixiv库 [PixivClient](https://github.com/cssxsh/pixiv-client) ,通过清除ServerHostName 绕过SNI审查,免代理 6 | 7 | [![Release](https://img.shields.io/github/v/release/cssxsh/pixiv-helper)](https://github.com/cssxsh/pixiv-helper/releases) 8 | ![Downloads](https://img.shields.io/github/downloads/cssxsh/pixiv-helper/total) 9 | [![MiraiForum](https://img.shields.io/badge/post-on%20MiraiForum-yellow)](https://mirai.mamoe.net/topic/289) 10 | 11 | **使用前应该查阅的相关文档或项目** 12 | 13 | * [User Manual](https://github.com/mamoe/mirai/blob/dev/docs/UserManual.md) 14 | * [Permission Command](https://github.com/mamoe/mirai/blob/dev/mirai-console/docs/BuiltInCommands.md#permissioncommand) 15 | * [Chat Command](https://github.com/project-mirai/chat-command) 16 | 17 | **Pixiv Helper 2 重构进行中,部分功能还不可用. 需要重新登录Pixiv 账号,你可以使用之前登录得到的 Token** 18 | 19 | 目前没有自动缓存清理,请使用 [#删除指令](#删除指令) 手动清理 20 | R18图会按照Pixiv所给信息过滤 21 | 群聊模式使用默认账号,私聊模式Pixiv账号和QQ号关联,初次使用请先 `/pixiv` 指令登陆账号 22 | 然后使用 `/cache recommended` 缓存系统推荐作品,然后再使用色图相关指令 23 | 推荐使用 `/task cache recommended` 定时自动缓存 24 | 25 | Gif图片需要由机器人自己合成,如果设备性能不足,请调整相关参数 26 | 27 | 自 `1.9.0` 起将数据库部分功能拆分 28 | 需要 [Mirai Hibernate Plugin](https://github.com/cssxsh/mirai-hibernate-plugin) 做前置插件 29 | 这是**必要**的 30 | MCL安装指令 `./mcl --update-package xyz.cssxsh.mirai:mirai-hibernate-plugin --channel maven-stable --type plugins` 31 | 32 | **打开浏览器,登录PIXIV** 需要 [Mirai Selenium Plugin](https://github.com/cssxsh/mirai-selenium-plugin) 做前置插件 33 | 并且需要代理配置(可以打开浏览器后,在浏览器中配置),浏览器登录只是其中一种登录方法,不必要安装 Selenium 插件 34 | 35 | 群聊默认输出最少作品信息,需要增加请使用 `/setting` 指令修改 36 | 37 | 发送模式可以使用 `/setting` 指令修改为闪照或撤销或转发 38 | 注意, 闪照等模式 并不会降低 `机器人被封禁` 的风险。 39 | 机器人被封禁的主要风险来自 40 | 41 | * QQ号是新注册的 42 | * Bot挂在服务器上,但是服务器IP被腾讯列为风险IP(腾讯通过IP确定 登录地区) 43 | * Bot被高频使用,(另外,`高频发图再高频撤销`属于不打自招,正常用户有这个手速吗?) 44 | * 发送大量违规链接,或者触发关键词 45 | 46 | ## 指令 47 | 48 | 注意: 使用前请确保可以 [在聊天环境执行指令](https://github.com/project-mirai/chat-command) 49 | 带括号的`/`前缀是可选的 50 | `<...>`中的是指令名,由空格隔开表示或,选择其中任一名称都可执行例如`/色图` 51 | `[...]`表示参数,当`[...]`后面带`?`时表示参数可选 52 | `{...}`表示连续的多个参数 53 | 54 | 本插件指令权限ID 格式为 `xyz.cssxsh.mirai.plugin.pixiv-helper:command.*`, `*` 是指令的第一指令名 55 | 例如 `/pixiv sina` 的权限ID为 `xyz.cssxsh.mirai.plugin.pixiv-helper:command.pixiv` 56 | 57 | ### Pixiv相关操作指令 58 | 59 | | 指令 | 描述 | 60 | |:----------------------------------------|:---------------------------| 61 | | `/ ` | 扫码登录关联了PIXIV的微博账号,以登录PIXIV | 62 | | `/ ` | 通过Cookie,登录PIXIV | 63 | | `/ ` | 打开浏览器,登录PIXIV | 64 | | `/ [token]` | 登录 通过 refresh token | 65 | | `/ [uid] [contact]?` | 绑定 Pixiv 账户 | 66 | | `/ ` | 账户池详情 | 67 | | `/ {uid}` | 为当前助手关注指定用户 | 68 | | `/ [uid]` | 关注指定用户的关注 | 69 | | `/ [uid] {words}?` | 添加指定作品收藏 | 70 | | `/ [pid]` | 删除指定作品收藏 | 71 | | `/ [tag]?` | 随机发送一个收藏的作品 | 72 | | `/ ` | 显示收藏列表 | 73 | 74 | **Pixiv helper 2 重构中,follow 和 mark 暂不可用** 75 | Pixiv helper 2 中 新加入 `/pixiv bind` 指令,此指令用于为一个联系人(群/用户)绑定一个 pixiv 账号(已登录) 76 | 77 | cookie 文件为工作目录下的 `cookie.json` 78 | 内容 为 浏览器插件 [EditThisCookie](http://www.editthiscookie.com/) 导出的Json 79 | EditThisCookie 安装地址 80 | [Chrome](https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg) 81 | [Firefox](https://addons.mozilla.org/firefox/downloads/file/3449327/editthiscookie2-1.5.0-fx.xpi) 82 | [Edge](https://microsoftedge.microsoft.com/addons/getproductdetailsbycrxid/ajfboaconbpkglpfanbmlfgojgndmhmc?hl=zh-CN&gl=CN) 83 | 84 | ### 色图相关指令 85 | 86 | | 指令 | 描述 | 87 | |:--------------------------------------------------|:------------------------| 88 | | `(/)` | 缓存中随机一张色图 | 89 | | `(/) [pid] [flush]?` | 获取指定ID图片 | 90 | | `(/) [word] [bookmark]? [fuzzy]?` | 随机指定TAG图片 | 91 | | `(/) [limit]? [word]?` | 随机一组色号图,默认30张 | 92 | | `(/) [uid]` | 根据画师UID随机发送画师作品 | 93 | | `(/) [name]` | 根据画师name或者alias随机发送画师作品 | 94 | | `(/) [name] [uid]` | 设置画师alias | 95 | | `(/) ` | 显示别名列表 | 96 | | `(/) [uid]` | 获取画师信息 | 97 | | `(/) [name] [limit]?` | 搜索画师 | 98 | | `(/) [image]?` | saucenao、ascii2d 搜索图片 | 99 | 100 | **Pixiv helper 2 重构中,illustrator 暂不可用** 101 | 102 | 色图指令基于缓存信息,使用前请先缓存一定量的作品,推荐使用 `/cache recommended` 指令 103 | 使用色图指令时 指令后附带 `更好`, 可以使收藏数比前一张更高, 如果两次色图指令间隔小于触发时间(默认时间10s)也会触发这个效果 104 | tag指令检索结果过少时,会自动触发缓存 105 | tag指令可以尝试按照格式 `角色名(作品名)` 检索角色, 举例 `红(明日方舟)` 106 | tag指令多keyword时,请使用 `_`,`|`,`,`, `+` 等符号将keyword连接起来,不要使用空格,举例 `明日方舟+巨乳` 107 | `[image]?` 为空时会从`回复消息`,`最近图片`获取 108 | `bookmark` 参数指收藏数过滤 109 | `fuzzy` 参数指模糊搜索 110 | boom指令使用时 111 | 无 `word` 会随机给出色图 112 | `word` 为数字时会查找对应uid画师的色图 113 | 其余情况则按 `tag` 处理 114 | 115 | 画师别名的`uid`为0时表示删除指定别名 116 | 117 | 搜图使用 的 api,无KEY时,每天限额 100次, KEY参数在设置中添加 118 | 举例: 119 | ![从指令参数中获取](image/search_1.png) 120 | ![从回复消息中获取](image/search_2.png) 121 | ![从最近图片中获取](image/search_3.png) 122 | ![从输入等待中获取](image/search_4.png) 123 | 124 | `1.9.1` 开始,添加 通过At来搜索头像的功能 125 | 126 | 当 saucenao 的 搜索结果不足时,会自动补充 ascii2d 的搜索结果 127 | 128 | ### 缓存指令 129 | 130 | | 指令 | 描述 | 131 | |:---------------------------------|:---------------| 132 | | `/ ` | 缓存关注推送 | 133 | | `/ [mode] [date]?` | 缓存指定排行榜信息 | 134 | | `/ ` | 缓存推荐作品 | 135 | | `/ [uid]` | 缓存用户的收藏中缓存色图作品 | 136 | | `/ [fluhsh]?` | 缓存关注画师作品 | 137 | | `/ [jump]?` | 缓存关注画师收藏 | 138 | | `/ [uid]` | 缓存指定画师作品 | 139 | | `/ [word]` | 缓存搜索tag得到的作品 | 140 | | `/ [range]?` | 缓存色图画师的作品 | 141 | | `/ [range]?` | 缓存色图画师的收藏 | 142 | | `/ ` | 缓存搜索记录 | 143 | | `/ [name]` | 停止缓存任务 | 144 | | `/ ` | 缓存任务详情 | 145 | 146 | **Pixiv helper 2 重构中,部分缓存指令 暂不可用** 147 | 148 | `[uid]?` 会自动填充当前用户 149 | 150 | `mode` 可选值: `MONTH`, `WEEK`, `WEEK_ORIGINAL`, `WEEK_ROOKIE`, `DAY`, `DAY_MALE`, `DAY_FEMALE`, `DAY_MANGA` 151 | 152 | ### 任务指令 153 | 154 | | 指令 | 描述 | 155 | |:-----------------------------------------|:---------| 156 | | `/ [uid] [cron] [target]?` | 推送用户新作品 | 157 | | `/ [mode] [cron] [target]?` | 推送排行榜新作品 | 158 | | `/ [cron] [target]?` | 推送关注用户作品 | 159 | | `/ [cron] [target]?` | 推送推荐作品 | 160 | | `/ [cron]? [target]?` | 推送热门标签 | 161 | | `/ [uid] [cron] {args}` | 数据自动缓存 | 162 | | `/ [id] [cron]` | 查看任务详情 | 163 | | `/ ` | 查看任务详情 | 164 | | `/ [id]` | 删除任务 | 165 | 166 | **Pixiv helper 2 重构中,部分任务指令 暂不可用** 167 | 168 | 备份文件优先推送到群文件,其次百度云 169 | duration 单位分钟,默认3小时 170 | `/task cache {args}` 是 task 和 cache 指令的组合,举例,`/task cache recommended` 171 | 172 | ### 设置指令 173 | 174 | | 指令 | 描述 | 175 | |:----------------------------------|:---------------------| 176 | | `/ [sec]` | 设置连续发送间隔时间, 单位秒 | 177 | | `/ ` | 设置Task发送模式 | 178 | | `/ ` | 设置是否显示Pixiv Cat 原图链接 | 179 | | `/ ` | 设置是否显示TAG INFO | 180 | | `/ ` | 设置是否显示作品属性 | 181 | | `/ ` | ~~设置cooling置零~~ 废除 | 182 | | `/ [num]` | 设置显示最大图片数 | 183 | | `/ [type] [ms]?` | 设置发送模式 | 184 | 185 | **Pixiv helper 2 重构中,部分设置指令 暂不可用** 186 | 187 | 发送模式 有三种 `NORMAL, FLASH, RECALL`, `ms` 是Recall的延迟时间,单位毫秒 188 | 注意:`FLASH, RECALL` 这两种模式 并不会降低 `机器人被封禁` 的风险 189 | `forward`, `link`, `tag`, `attr` 使用指令后会对当前值取反 190 | 191 | ### 统计信息指令 192 | 193 | | 指令 | 描述 | 194 | |:-----------------------------|:------------| 195 | | `/ [target]?` | 获取用户信息 | 196 | | `/ [target]?` | 获取群组信息 | 197 | | `/ [limit]?` | 获取TAG指令统计信息 | 198 | | `/ ` | 获取缓存信息 | 199 | 200 | ### 删除指令 201 | 202 | | 指令 | 描述 | 203 | |:----------------------------------------|:------------| 204 | | `/ [pid] [record]?` | 删除指定作品 | 205 | | `/ [uid] [record]?` | 删除指定用户作品 | 206 | | `/ [max] [record]?` | 删除小于指定收藏数作品 | 207 | | `/ [min] [record]?` | 删除大于指定页数作品 | 208 | | `/ [record]?` | 删除漫画作品 | 209 | | `/ ` | 删除已记录作品 | 210 | 211 | 第二参数 record 表明是否写入数据库,默认为否,只删除图片文件 212 | 213 | ## URL 自动解析 214 | 215 | **Pixiv helper 2 重构中,部分自动解析 暂不可用** 216 | 217 | 权限 id: `xyz.cssxsh.mirai.plugin.pixiv-helper:url` 218 | 匹配一下正则表达式的URL将会被解析 219 | 220 | ``` 221 | val URL_ARTWORK_REGEX = """(?<=pixiv\.net/(i|artworks)/|illust_id=)\d+""".toRegex() 222 | val URL_USER_REGEX = """(?<=pixiv\.net/(u/|users/|member\.php\?id=))\d+""".toRegex() 223 | val URL_PIXIV_ME_REGEX = """(?<=pixiv\.me/)[\w-]{3,32}""".toRegex() 224 | ``` 225 | 226 | ## 设置 227 | 228 | ### PixivHelperSettings.yml 229 | 230 | * `cache_path` 缓存目录 231 | * `backup_path` 备份目录 232 | * `temp_path` 临时目录 233 | * `ero_chunk` 色图分块大小 和自动触发TAG缓存有关 234 | * `ero_up_expire` 色图自动触发更高收藏数的最大时间,单位毫秒 235 | * `ero_work_types` 涩图标准 内容类型 `ILLUST, UGOIRA, MANGA`, 为空则全部符合 236 | * `ero_bookmarks` 涩图标准 收藏 237 | * `ero_page_count` 涩图标准 页数 238 | * `ero_tag_exclude` 涩图标准 排除的正则表达式 239 | * `ero_user_exclude` 涩图标准 排除的UID 240 | * `pximg` 反向代理, 若非特殊情况不要修改这个配置,保持留空,可选代理 `i.pixiv.re, i.pixiv.cat` 241 | * `proxy` API代理 242 | * `proxy_download` DOWNLOAD代理 `图片下载器会对代理产生很大的负荷`,请十分谨慎的开启这个功能 243 | * ~~timeout_api~~ API超时时间, 单位ms 244 | * `timeout_download` DOWNLOAD超时时间, 单位ms 245 | * `block_size` DOWNLOAD分块大小, 单位B, 默认 523264, 为零时, 不会分块下载 246 | * `tag_sfw` tag 是否过滤r18 依旧不会放出图片 247 | * `ero_sfw` ero 是否过滤r18 依旧不会放出图片 248 | * ~~cache_capacity~~ 下载缓存容量,同时下载的任务上限 249 | * ~~cache_jump~~ 缓存是否跳过下载 250 | * ~~upload~~ 压缩完成后是否上传百度云,不上传百度云则会尝试发送文件 251 | 252 | ### ImageSearchConfig.yml 253 | 254 | * `key` KEY 不是必须的,无KEY状态下,根据IP每天可以搜索 100 次,有KEY状态下搜索次数依据于账户 255 | KEY 参数请到 注册账号, 256 | 在用户页面 获得的KEY填入 257 | 信息只在启动时读取,修改后需重启 258 | * `limit` 显示的搜索结果数 259 | * `bovw` ascii2d 检索类型,false色合検索 true特徴検索 260 | * `wait` 图片等待时间,单位秒 261 | * `forward` 转发方式发送搜索结果 262 | 263 | ### PixivGifConfig.yml 264 | 265 | * `quantizer` 编码器, `com.squareup.gifencoder.ColorQuantizer` 的实现 266 | 目前可选值,图片质量和所需性能按顺序递增, 推荐使用 `OctTreeQuantizer` 267 | `com.squareup.gifencoder.UniformQuantizer` 268 | `com.squareup.gifencoder.MedianCutQuantizer` 269 | `com.squareup.gifencoder.OctTreeQuantizer` 270 | `com.squareup.gifencoder.KMeansQuantizer` 271 | `xyz.cssxsh.pixiv.tool.OpenCVQuantizer` (需要 安装 OpenCV, 对应 `jar` 放进 `plugins` 文件夹) 272 | * `ditherer` 抖动器, `com.squareup.gifencoder.Ditherer` 的实现 273 | 目前可选值, 推荐使用 `AtkinsonDitherer` 274 | `com.squareup.gifencoder.FloydSteinbergDitherer` 275 | `com.squareup.gifencoder.NearestColorDitherer` 276 | `xyz.cssxsh.pixiv.tool.AtkinsonDitherer` 277 | `xyz.cssxsh.pixiv.tool.JJNDitherer` 278 | `xyz.cssxsh.pixiv.tool.SierraLiteDitherer` 279 | `xyz.cssxsh.pixiv.tool.StuckiDitherer` 280 | * `disposal` 切换方法 281 | 可选值 `UNSPECIFIED`, `DO_NOT_DISPOSE`, `RESTORE_TO_BACKGROUND`, `RESTORE_TO_PREVIOUS` 282 | * `max_count` OpenCVQuantizer 最大迭代数 283 | 284 | ### System.getProperty 285 | 286 | * `pixiv.rate.limit.delay` 默认 `3 * 60 * 1000L` ms 287 | * `pixiv.download.async` 默认 `32` 288 | 289 | ### hibernate.properties 290 | 291 | 如果不是特殊需要,使用默认的 SQLite 配置就好 292 | 配置 mysql 举例 (字符集要设置为utf8mb4_bin),其他数据库类推 293 | 配置 文件 294 | 295 | ``` 296 | hibernate.connection.url=jdbc:mysql://localhost:3306/pixiv?autoReconnect=true 297 | hibernate.connection.driver_class=com.mysql.cj.jdbc.Driver 298 | hibernate.connection.CharSet=utf8mb4 299 | hibernate.connection.useUnicode=true 300 | hibernate.connection.username=username 301 | hibernate.connection.password=password 302 | hibernate.dialect=org.hibernate.dialect.MySQL5Dialect 303 | hibernate.connection.provider_class=org.hibernate.hikaricp.internal.HikariCPConnectionProvider 304 | hibernate.hbm2ddl.auto=none 305 | hibernate-connection-autocommit=true 306 | hibernate.connection.show_sql=false 307 | hibernate.autoReconnect=true 308 | ``` 309 | 310 | 关于表的自动创建可以查看 [model](/src/main/resources/xyz/cssxsh/mirai/pixiv/model) 311 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") version "1.8.22" 3 | kotlin("plugin.serialization") version "1.8.22" 4 | kotlin("plugin.jpa") version "1.8.22" 5 | 6 | id("net.mamoe.mirai-console") version "2.16.0" 7 | } 8 | 9 | group = "xyz.cssxsh" 10 | version = "2.0.0" 11 | 12 | repositories { 13 | mavenCentral() 14 | } 15 | 16 | dependencies { 17 | compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:2.8.0") 18 | compileOnly("xyz.cssxsh.mirai:mirai-selenium-plugin:2.5.1") 19 | compileOnly("xyz.cssxsh.mirai:mirai-skia-plugin:1.3.2") 20 | compileOnly("xyz.cssxsh:arknights-helper:2.3.1") 21 | implementation("org.jsoup:jsoup:1.17.2") 22 | implementation("xyz.cssxsh.pixiv:pixiv-client:1.3.1") 23 | implementation("com.cronutils:cron-utils:9.2.1") 24 | implementation("org.jcodec:jcodec:0.2.5") 25 | implementation("org.jcodec:jcodec-javase:0.2.5") 26 | testImplementation(kotlin("test")) 27 | testImplementation("xyz.cssxsh.mirai:mirai-hibernate-plugin:2.8.0") 28 | testImplementation("xyz.cssxsh.mirai:mirai-selenium-plugin:2.5.1") 29 | testImplementation("xyz.cssxsh.mirai:mirai-skia-plugin:1.3.2") 30 | // 31 | implementation(platform("net.mamoe:mirai-bom:2.16.0")) 32 | compileOnly("net.mamoe:mirai-core") 33 | compileOnly("net.mamoe:mirai-core-utils") 34 | compileOnly("net.mamoe:mirai-console-compiler-common") 35 | testImplementation("net.mamoe:mirai-logging-slf4j") 36 | // 37 | implementation(platform("org.slf4j:slf4j-parent:2.0.12")) 38 | testImplementation("org.slf4j:slf4j-simple") 39 | } 40 | 41 | mirai { 42 | jvmTarget = JavaVersion.VERSION_11 43 | } 44 | 45 | kotlin { 46 | explicitApi() 47 | } 48 | 49 | tasks { 50 | test { 51 | useJUnitPlatform() 52 | } 53 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/pixiv-helper/bc71996571e532c2688852290d1216780cfc249a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /image/search_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/pixiv-helper/bc71996571e532c2688852290d1216780cfc249a/image/search_1.png -------------------------------------------------------------------------------- /image/search_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/pixiv-helper/bc71996571e532c2688852290d1216780cfc249a/image/search_2.png -------------------------------------------------------------------------------- /image/search_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/pixiv-helper/bc71996571e532c2688852290d1216780cfc249a/image/search_3.png -------------------------------------------------------------------------------- /image/search_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/pixiv-helper/bc71996571e532c2688852290d1216780cfc249a/image/search_4.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "pixiv-helper" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivCacheLoader.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import io.ktor.http.* 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.sync.* 6 | import net.mamoe.mirai.utils.* 7 | import xyz.cssxsh.mirai.pixiv.model.* 8 | import xyz.cssxsh.mirai.pixiv.task.* 9 | import xyz.cssxsh.pixiv.* 10 | import xyz.cssxsh.pixiv.apps.* 11 | import java.io.File 12 | import kotlin.coroutines.* 13 | 14 | public object PixivCacheLoader : CoroutineScope { 15 | private val logger by lazy { MiraiLogger.Factory.create(this::class, identity = "pixiv-cache-loader") } 16 | 17 | override val coroutineContext: CoroutineContext = 18 | CoroutineName(name = "pixiv-cache-loader") + SupervisorJob() + CoroutineExceptionHandler { context, throwable -> 19 | logger.warning({ "$throwable in $context" }, throwable) 20 | } 21 | 22 | private val jobs: MutableMap = HashMap() 23 | 24 | private val write = Mutex() 25 | 26 | private suspend fun IllustInfo.downalod() { 27 | try { 28 | when (type) { 29 | WorkContentType.ILLUST -> images(illust = this) 30 | WorkContentType.UGOIRA -> ugoira(illust = this) 31 | WorkContentType.MANGA -> Unit 32 | } 33 | } catch (cause: Exception) { 34 | logger.warning({ "作品(${pid})<${createAt}>[${user.id}]下载失败" }, cause) 35 | } 36 | } 37 | 38 | public suspend fun images(illust: IllustInfo): List { 39 | val folder = images(pid = illust.pid).apply { mkdirs() } 40 | 41 | /** 42 | * 全部Url 43 | */ 44 | val urls = illust.getOriginImageUrls().filter { "${illust.pid}" in it.encodedPath } 45 | 46 | /** 47 | * 需要下载的 Url 48 | */ 49 | val downloads = mutableListOf() 50 | 51 | /** 52 | * 过滤Url 53 | */ 54 | val files = urls.map { url -> 55 | folder.resolve(url.filename).apply { 56 | if (exists().not()) { 57 | downloads.add(url) 58 | } 59 | } 60 | } 61 | 62 | if (downloads.isNotEmpty()) { 63 | fun FileInfo(url: Url, bytes: ByteArray) = FileInfo( 64 | pid = illust.pid, 65 | index = with(url.encodedPath) { 66 | val end = lastIndexOf('.') 67 | val start = lastIndexOf('p', end) + 1 68 | substring(start, end) 69 | .toIntOrNull() ?: throw IllegalArgumentException(url.encodedPath) 70 | }, 71 | md5 = bytes.md5().toUHexString(""), 72 | url = url.toString(), 73 | size = bytes.size 74 | ) 75 | 76 | val results = mutableListOf() 77 | var size = 0L 78 | 79 | downloads.removeIf { url -> 80 | val file = TempFolder.resolve(url.filename) 81 | val exists = file.exists() 82 | if (exists) { 83 | logger.info { "从[${file}]移动文件" } 84 | results.add(FileInfo(url = url, bytes = file.readBytes())) 85 | file.renameTo(folder.resolve(url.filename)) 86 | } else { 87 | false 88 | } 89 | } 90 | 91 | PixivHelperDownloader.downloadImageUrls(urls = downloads) { url, deferred -> 92 | try { 93 | val bytes = deferred.await() 94 | TempFolder.resolve(url.filename).writeBytes(bytes) 95 | size += bytes.size 96 | results += FileInfo(url = url, bytes = bytes) 97 | } catch (cause: Exception) { 98 | logger.warning({ "[$url]下载失败" }, cause) 99 | } 100 | } 101 | 102 | try { 103 | results.merge() 104 | } catch (cause: Exception) { 105 | logger.warning({ "记录数据失败" }, cause) 106 | } 107 | 108 | with(illust) { 109 | logger.debug { 110 | "作品(${pid})<${createAt}>[${user.id}][${type}][${title}][${bytes(size)}]{${totalBookmarks}}下载完成" 111 | } 112 | } 113 | 114 | for (url in downloads) { 115 | TempFolder.resolve(url.filename).apply { 116 | if (exists()) renameTo(folder.resolve(url.filename)) 117 | } 118 | } 119 | } 120 | 121 | return files 122 | } 123 | 124 | public suspend fun ugoira(illust: IllustInfo, flush: Boolean = false): File { 125 | val json = ugoira(pid = illust.pid) 126 | val metadata: UgoiraMetadata 127 | if (flush || !json.exists()) { 128 | metadata = PixivClientPool.free().ugoiraMetadata(pid = illust.pid).ugoira 129 | metadata.write(json) 130 | } else { 131 | metadata = json.readUgoiraMetadata() 132 | } 133 | return try { 134 | PixivSkikoGifEncoder.build(illust = illust, metadata = metadata, flush = flush) 135 | } catch (_: NoClassDefFoundError) { 136 | PixivHelperGifEncoder.build(illust = illust, metadata = metadata, flush = flush) 137 | } 138 | } 139 | 140 | public fun cache(task: PixivCacheTask, handler: TaskCompletionHandler = { _, _ -> }) { 141 | if (jobs[task.name]?.isActive == true) throw IllegalArgumentException("${task.name} 任务已存在") 142 | 143 | jobs[task.name] = launch { 144 | var cause: Throwable? = null 145 | val users: MutableMap = HashMap() 146 | try { 147 | task.flow.collect { page -> 148 | val artworks = page.merge(users = users) 149 | 150 | val downloads: MutableList> = ArrayList() 151 | for (illust in page) { 152 | if (artworks[illust.pid]?.deleted == true) continue 153 | if (!illust.isEro()) continue 154 | if (task.write) write.withLock { 155 | illust.write() 156 | } 157 | downloads.add(async(Dispatchers.IO) { illust.downalod() }) 158 | } 159 | 160 | downloads.awaitAll() 161 | } 162 | } catch (exception: CancellationException) { 163 | cause = exception 164 | } catch (exception: Exception) { 165 | logger.warning({ "缓存加载错误" }, exception) 166 | cause = exception 167 | } finally { 168 | jobs.remove(task.name) 169 | handler.invoke(task, cause) 170 | } 171 | } 172 | } 173 | 174 | public fun detail(): String { 175 | return jobs.entries.joinToString("\n") { (name, job) -> 176 | "$name -> $job" 177 | } 178 | } 179 | 180 | public fun stop(name: String) { 181 | jobs.remove(name)?.cancel() ?: throw NoSuchElementException("任务 $name 不存在") 182 | } 183 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivClientPool.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import io.ktor.client.network.sockets.* 4 | import io.ktor.client.statement.* 5 | import io.ktor.util.* 6 | import kotlinx.coroutines.* 7 | import net.mamoe.mirai.utils.* 8 | import xyz.cssxsh.mirai.pixiv.data.* 9 | import xyz.cssxsh.pixiv.* 10 | import xyz.cssxsh.pixiv.auth.* 11 | import xyz.cssxsh.pixiv.exception.* 12 | import java.io.IOException 13 | import kotlin.coroutines.* 14 | import kotlin.properties.* 15 | import kotlin.reflect.* 16 | 17 | /** 18 | * 客户端池,key 是 pixiv uid 19 | */ 20 | public object PixivClientPool : ReadOnlyProperty, CoroutineScope { 21 | private val logger by lazy { MiraiLogger.Factory.create(this::class, identity = "pixiv-client-pool") } 22 | 23 | override val coroutineContext: CoroutineContext = 24 | CoroutineName(name = "pixiv-client-pool") + SupervisorJob() + CoroutineExceptionHandler { context, throwable -> 25 | logger.warning({ "$throwable in $context" }, throwable) 26 | } 27 | 28 | internal val clients: MutableMap = java.util.concurrent.ConcurrentHashMap() 29 | 30 | internal val binded: MutableMap get() = PixivAuthData.binded 31 | 32 | private val authed: Set get() = PixivAuthData.results.keys 33 | 34 | internal var default: Long by PixivAuthData::default 35 | 36 | override fun getValue(thisRef: PixivHelper, property: KProperty<*>): PixivAuthClient { 37 | return get(id = thisRef.id) ?: free() 38 | } 39 | 40 | public fun free(): PixivAuthClient { 41 | val uid = authed.randomOrNull() ?: throw NoSuchElementException("账号池为空,请登录 Pixiv 账号") 42 | return FreeClient(delegate = user(uid = uid)) 43 | } 44 | 45 | public fun console(): AuthClient? = clients[default] 46 | 47 | public fun user(uid: Long): AuthClient { 48 | return clients.getOrPut(key = uid) { 49 | check(uid in authed) { "$uid 此账户未登录" } 50 | AuthClient(uid = uid) 51 | } 52 | } 53 | 54 | public operator fun get(id: Long): AuthClient? { 55 | return user(uid = binded[id] ?: return null) 56 | } 57 | 58 | public fun bind(uid: Long, subject: Long?) { 59 | if (subject == null) { 60 | default = uid 61 | } else { 62 | binded[subject] = uid 63 | } 64 | } 65 | 66 | public suspend fun auth(block: suspend (PixivAuthClient) -> Unit) { 67 | val client = TempClient() 68 | block.invoke(client) 69 | val auth = client.auth 70 | if (auth != null) { 71 | PixivAuthData += auth 72 | clients[auth.user.uid] = AuthClient(uid = auth.user.uid) 73 | } 74 | } 75 | 76 | private val handle: suspend PixivAuthClient.(Throwable) -> Boolean = { throwable -> 77 | when (throwable) { 78 | is SocketTimeoutException, 79 | is ConnectTimeoutException -> { 80 | logger.warning { "Api 超时, 已忽略: ${throwable.message}" } 81 | true 82 | } 83 | is java.net.UnknownHostException, 84 | is java.net.NoRouteToHostException -> false 85 | is IOException -> { 86 | logger.warning { "Api 错误, 已忽略: $throwable" } 87 | true 88 | } 89 | is AppApiException -> { 90 | val url = throwable.response.request.url 91 | val request = throwable.response.request.headers.toMap() 92 | val response = throwable.response.headers.toMap() 93 | when { 94 | "Please check your Access Token to fix this." in throwable.message -> { 95 | logger.warning { "PIXIV API OAuth 错误, 将刷新 Token $url with $request" } 96 | try { 97 | refresh() 98 | } catch (cause: Exception) { 99 | logger.warning { "刷新 Token 失败 $cause" } 100 | } 101 | true 102 | } 103 | "Rate Limit" in throwable.message -> { 104 | logger.warning { "PIXIV API限流, 将延时: ${PIXIV_RATE_LIMIT_DELAY}ms $url with $response" } 105 | delay(PIXIV_RATE_LIMIT_DELAY) 106 | true 107 | } 108 | else -> false 109 | } 110 | } 111 | else -> false 112 | } 113 | } 114 | 115 | public class TempClient : PixivAuthClient() { 116 | override val config: PixivConfig = DEFAULT_PIXIV_CONFIG.copy() 117 | 118 | public override var auth: AuthResult? = null 119 | 120 | override val coroutineContext: CoroutineContext = 121 | PixivClientPool.coroutineContext + CoroutineName(name = "temp-client") 122 | 123 | override val ignore: suspend (Throwable) -> Boolean get() = { handle(it) } 124 | } 125 | 126 | public class AuthClient(public val uid: Long) : PixivAuthClient() { 127 | 128 | override val config: PixivConfig = DEFAULT_PIXIV_CONFIG.copy() 129 | 130 | public override var auth: AuthResult? by PixivAuthData 131 | 132 | override val coroutineContext: CoroutineContext = 133 | PixivClientPool.coroutineContext + CoroutineName(name = "auth-client-$uid") 134 | 135 | override val ignore: suspend (Throwable) -> Boolean = { handle(it) } 136 | } 137 | 138 | public class FreeClient(private val delegate: AuthClient) : PixivAuthClient() { 139 | 140 | override val config: PixivConfig = DEFAULT_PIXIV_CONFIG.copy() 141 | 142 | override var auth: AuthResult? by delegate::auth 143 | 144 | override val coroutineContext: CoroutineContext = 145 | PixivClientPool.coroutineContext + CoroutineName(name = "free-client") 146 | 147 | override val ignore: suspend (Throwable) -> Boolean = { handle(it) } 148 | } 149 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivConfig.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import net.mamoe.mirai.utils.* 4 | import xyz.cssxsh.mirai.pixiv.data.* 5 | import xyz.cssxsh.mirai.pixiv.model.* 6 | import xyz.cssxsh.mirai.pixiv.tools.* 7 | import xyz.cssxsh.pixiv.* 8 | import xyz.cssxsh.pixiv.fanbox.* 9 | import xyz.cssxsh.pixiv.tool.* 10 | import kotlin.math.* 11 | 12 | internal val PIXIV_IMAGE_SOFTBANK = (134..147).map { "210.140.92.${it}" } 13 | 14 | internal val PIXIV_API_SOFTBANK = ((199..223) + (224..229)).map { "210.140.131.${it}" } 15 | 16 | internal val PIXIV_SKETCH_SOFTBANK = listOf("210.140.175.130", "210.140.174.37", "210.140.170.179") 17 | 18 | internal val SAUCENAO_ORIGIN = listOf("45.32.0.237", "chr1.saucenao.com") 19 | 20 | internal val PIXIV_RATE_LIMIT_DELAY: Long by lazy { 21 | System.getProperty("pixiv.rate.limit.delay")?.toLong() ?: (3 * 60 * 1000L) 22 | } 23 | 24 | internal val PIXIV_DOWNLOAD_ASYNC: Int by lazy { 25 | System.getProperty("pixiv.download.async")?.toInt() ?: 32 26 | } 27 | 28 | internal val PIXIV_HOST = mapOf( 29 | "*.pximg.net" to PIXIV_IMAGE_SOFTBANK, 30 | "*.pixiv.net" to PIXIV_API_SOFTBANK, 31 | "sketch.pixiv.net" to PIXIV_SKETCH_SOFTBANK, 32 | "*.saucenao.com" to SAUCENAO_ORIGIN 33 | ) 34 | 35 | internal val DEFAULT_PIXIV_CONFIG: PixivConfig by lazy { 36 | PixivConfig(host = DEFAULT_PIXIV_HOST + PIXIV_HOST, proxy = ProxyApi) 37 | } 38 | 39 | internal fun initConfiguration() { 40 | CacheFolder.mkdirs() 41 | BackupFolder.mkdirs() 42 | TempFolder.mkdirs() 43 | ProfileFolder.mkdirs() 44 | ArticleFolder.mkdirs() 45 | UgoiraImagesFolder.mkdirs() 46 | OtherImagesFolder.mkdirs() 47 | ExistsImagesFolder.mkdirs() 48 | logger.info { "CacheFolder: ${CacheFolder.toPath().toUri()}" } 49 | logger.info { "BackupFolder: ${BackupFolder.toPath().toUri()}" } 50 | logger.info { "TempFolder: ${TempFolder.toPath().toUri()}" } 51 | if (ProxyMirror.isNotBlank()) { 52 | logger.warning { "镜像代理已开启 i.pximg.net -> $ProxyMirror 不推荐修改这个配置,建议保持留空" } 53 | } 54 | if (ProxyApi.isNotBlank()) { 55 | logger.warning { "已加载 API 代理 $ProxyApi API代理可能会导致SSL连接异常,请十分谨慎的开启这个功能" } 56 | } 57 | if (ProxyDownload.isNotBlank()) { 58 | logger.warning { "已加载 DOWNLOAD 代理 $ProxyDownload 图片下载器会对代理产生很大的负荷,请十分谨慎的开启这个功能" } 59 | } 60 | if (BlockSize <= 0) { 61 | logger.warning { "分块下载关闭,通常来说分块下载可以加快下载速度,建议开启,但分块不宜太小" } 62 | } else if (BlockSize < HTTP_KILO) { 63 | logger.warning { "下载分块过小" } 64 | } 65 | 66 | ImageSearcher.key = ImageSearchConfig.key 67 | 68 | with(PixivGifConfig) { 69 | when { 70 | quantizer !in QUANTIZER_LIST -> { 71 | logger.warning { "PixivGifConfig.quantizer 非原生" } 72 | } 73 | "com.squareup.gifencoder.OctTreeQuantizer" != quantizer -> { 74 | logger.info { "目前GIF合成只有靠CPU算力,推荐使用 OctTreeQuantizer " } 75 | } 76 | } 77 | @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") 78 | System.setProperty(OpenCVQuantizer.MAX_COUNT, maxCount.toString()) 79 | if (ditherer !in DITHERER_LIST) { 80 | logger.warning { "PixivGifConfig.ditherer 非原生" } 81 | } 82 | } 83 | 84 | factory.openSession().use { session -> 85 | create(session) 86 | } 87 | val count = ArtWorkInfo.count() 88 | if (count < EroChunk) { 89 | logger.warning { "缓存数 $count < ${EroChunk},建议使用指令( /cache recommended )进行缓存" } 90 | } else { 91 | logger.info { "缓存数 $count " } 92 | } 93 | } 94 | 95 | /** 96 | * * `https://www.pixiv.net/i/79695391` 97 | * * `https://www.pixiv.net/artworks/79695391` 98 | * * `https://www.pixiv.net/en/artworks/79695391` 99 | * * `https://www.pixiv.net/member_illust.php?mode=medium&illust_id=82876433` 100 | */ 101 | internal val URL_ARTWORK_REGEX = """(?<=pixiv\.net/(en/)?(i|artworks)/|illust_id=)\d+""".toRegex() 102 | 103 | /** 104 | * * `https://www.pixiv.net/u/902077` 105 | * * `https://www.pixiv.net/users/902077` 106 | * * `https://www.pixiv.net/en/users/902077` 107 | * * `https://www.pixiv.net/member.php?id=902077` 108 | */ 109 | internal val URL_USER_REGEX = """(?<=pixiv\.net/(en/)?(u/|users/|member\.php\?id=))\d+""".toRegex() 110 | 111 | /** 112 | * [リダイレクトURLサービス](https://www.pixiv.net/info.php?id=1554) 113 | * * `https://pixiv.me/milkpanda-yellow` 114 | */ 115 | internal val URL_PIXIV_ME_REGEX = """(?<=pixiv\.me/)[\w-]{3,32}""".toRegex() 116 | 117 | /** 118 | * * `https://official.fanbox.cc/` 119 | * * `https://www.fanbox.cc/@official` 120 | */ 121 | internal val URL_FANBOX_CREATOR_REGEX get() = FanBoxCreator.URL_FANBOX_CREATOR_REGEX 122 | 123 | /** 124 | * * `https://www.pixiv.net/fanbox/creator/31386013` 125 | */ 126 | internal val URL_FANBOX_ID_REGEX = """(?<=pixiv\.net/fanbox/creator/)\d+""".toRegex() 127 | 128 | /** 129 | * * `https://www.pixivision.net/zh/a/6858` 130 | */ 131 | internal val URL_PIXIVISION_ARTICLE = """(?<=pixivision\.net/[\w-]{2,5}/a/)\d+""".toRegex() 132 | 133 | /** 134 | * * `https://twitter.com/twitter` 135 | */ 136 | internal val URL_TWITTER_SCREEN = """(?<=twitter\.com/(#!/)?)\w{4,15}""".toRegex() 137 | 138 | 139 | internal val DELETE_REGEX = """該当作品は削除されたか|作品已删除或者被限制|该作品已被删除,或作品ID不存在。""".toRegex() 140 | 141 | internal const val PixivMirrorHost = "i.pixiv.re" 142 | 143 | internal val MIN_SIMILARITY = sqrt(5.0).minus(1).div(2) 144 | 145 | internal const val ERO_CHUNK = 16 146 | 147 | internal const val ERO_UP_EXPIRE = 10 * 1000L 148 | 149 | internal const val ERO_BOOKMARKS = 1L shl 12 150 | 151 | internal const val ERO_PAGE_COUNT = 3 152 | 153 | internal val ERO_TAG_EXCLUDE = """([hH]olo|僕のヒーローアカデミア)""".toRegex() 154 | 155 | internal const val LOAD_LIMIT = 5_000L 156 | 157 | internal const val TAG_TOP_LIMIT = 10 158 | 159 | internal const val TAG_DELIMITERS = """_-&+|/\,(),、—()""" 160 | 161 | internal val MAX_RANGE = 0..999_999_999L 162 | 163 | private const val OFFSET_STEP = 1_000_000L 164 | 165 | internal val ALL_RANGE = (MAX_RANGE step OFFSET_STEP).map { offset -> offset until (offset + OFFSET_STEP) } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivEventListener.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.http.* 5 | import net.mamoe.mirai.console.command.CommandSender.Companion.toCommandSender 6 | import net.mamoe.mirai.console.command.UserCommandSender 7 | import net.mamoe.mirai.console.permission.* 8 | import net.mamoe.mirai.console.permission.PermissionService.Companion.testPermission 9 | import net.mamoe.mirai.event.* 10 | import net.mamoe.mirai.event.events.* 11 | import net.mamoe.mirai.message.data.* 12 | import net.mamoe.mirai.utils.* 13 | import xyz.cssxsh.mirai.pixiv.event.* 14 | import xyz.cssxsh.mirai.pixiv.model.* 15 | import xyz.cssxsh.pixiv.apps.* 16 | 17 | public object PixivEventListener : SimpleListenerHost() { 18 | private val logger by lazy { MiraiLogger.Factory.create(this::class, identity = "pixiv-event-listener") } 19 | 20 | @EventHandler 21 | public fun PixivEvent.handle() { 22 | helper 23 | // TODO: stop send 24 | } 25 | 26 | public var paserPermission: Permission = Permission.getRootPermission() 27 | 28 | @EventHandler 29 | public suspend fun MessageEvent.handle() { 30 | val content = message.findIsInstance()?.content ?: return 31 | if (this is MessageSyncEvent) return 32 | val context = toCommandSender() as UserCommandSender 33 | if (paserPermission.testPermission(context).not()) return 34 | URL_ARTWORK_REGEX.find(content)?.let { match -> 35 | logger.info { "匹配ARTWORK(${match.value})" } 36 | context.withHelper { 37 | loadIllustInfo(pid = match.value.toLong(), client = client) 38 | } 39 | } 40 | URL_USER_REGEX.find(content)?.let { match -> 41 | logger.info { "匹配USER(${match.value})" } 42 | context.withHelper { 43 | client.userDetail(uid = match.value.toLong()) 44 | } 45 | } 46 | URL_PIXIV_ME_REGEX.find(content)?.let { match -> 47 | logger.info { "匹配USER(${match.value})" } 48 | context.withHelper { 49 | val uid = UserBaseInfo.get(account = match.value)?.uid ?: client.useHttpClient { http -> 50 | http.head("https://pixiv.me/${match.value}") 51 | .headers[HttpHeaders.Location] 52 | ?.let { URL_USER_REGEX.find(it) } 53 | ?.value 54 | ?.toLong() 55 | } 56 | if (uid != null) { 57 | client.userDetail(uid = uid) 58 | } else { 59 | null 60 | } 61 | } 62 | } 63 | URL_PIXIVISION_ARTICLE.find(content)?.let { match -> 64 | logger.info { "匹配ARTICLE(${match.value})" } 65 | // TODO: paser URL_PIXIVISION_ARTICLE 66 | } 67 | URL_FANBOX_CREATOR_REGEX.find(content)?.let { match -> 68 | logger.info { "匹配FANBOX(${match.value})" } 69 | // TODO: paser URL_FANBOX_CREATOR_REGEX 70 | } 71 | URL_FANBOX_ID_REGEX.find(content)?.let { match -> 72 | logger.info { "匹配FANBOX(${match.value})" } 73 | // TODO: paser URL_FANBOX_ID_REGEX 74 | } 75 | } 76 | } 77 | 78 | //object PixivHelperListener { 79 | // 80 | // private val listeners: MutableMap<String, Listener<*>> = HashMap() 81 | // 82 | // private infix fun String.with(listener: Listener<*>) = synchronized(listeners) { 83 | // listeners.put(this, listener)?.cancel() 84 | // } 85 | // 86 | // internal fun subscribe(channel: EventChannel<*>, permission: Permission): Unit = with(channel) { 87 | // "PixivUrl" with subscribeMessages { 88 | // URL_ARTWORK_REGEX finding { result -> 89 | // logger.info { "匹配ARTWORK(${result.value})" } 90 | // toCommandSender().takeIf { permission.testPermission(it) }?.sendIllustInfo(pid = result.value.toLong()) 91 | // } 92 | // URL_USER_REGEX finding { result -> 93 | // logger.info { "匹配USER(${result.value})" } 94 | // toCommandSender().takeIf { permission.testPermission(it) }?.sendUserInfo(uid = result.value.toLong()) 95 | // } 96 | // URL_PIXIV_ME_REGEX finding { result -> 97 | // logger.info { "匹配USER(${result.value})" } 98 | // toCommandSender().takeIf { permission.testPermission(it) }?.sendUserInfo(account = result.value) 99 | // } 100 | // URL_PIXIVISION_ARTICLE finding { result -> 101 | // logger.info { "匹配ARTICLE(${result.value})" } 102 | // toCommandSender().takeIf { permission.testPermission(it) }?.sendArticle(aid = result.value.toLong()) 103 | // } 104 | // URL_FANBOX_CREATOR_REGEX finding { result -> 105 | // if (result.value == "api" || result.value == "www") return@finding 106 | // logger.info { "匹配FANBOX(${result.value})" } 107 | // toCommandSender().takeIf { permission.testPermission(it) }?.sendCreatorInfo(id = result.value) 108 | // } 109 | // URL_FANBOX_ID_REGEX finding { result -> 110 | // logger.info { "匹配FANBOX(${result.value})" } 111 | // toCommandSender().takeIf { permission.testPermission(it) }?.sendCreatorInfo(uid = result.value.toLong()) 112 | // } 113 | // } 114 | // } 115 | // 116 | // internal fun stop() = synchronized(listeners) { 117 | // for ((_, listener) in listeners) listener.cancel() 118 | // listeners.clear() 119 | // } 120 | // 121 | // private suspend fun CommandSenderOnMessage<*>.sendIllustInfo(pid: Long) = withHelper { 122 | // getIllustInfo(pid = pid, flush = false) 123 | // } 124 | // 125 | // private suspend fun CommandSenderOnMessage<*>.sendUserInfo(uid: Long) = withHelper { 126 | // buildMessageByUser(uid = uid) 127 | // } 128 | // 129 | // private suspend fun CommandSenderOnMessage<*>.sendUserInfo(account: String) = withHelper { 130 | // buildMessageByUser(uid = redirect(account = account)) 131 | // } 132 | // 133 | // private suspend fun CommandSenderOnMessage<*>.sendCreatorInfo(id: String) = withHelper { 134 | // buildMessageByCreator(creator = creator.get(creatorId = id)) 135 | // } 136 | // 137 | // private suspend fun CommandSenderOnMessage<*>.sendCreatorInfo(uid: Long) = withHelper { 138 | // buildMessageByCreator(creator = creator.get(userId = uid)) 139 | // } 140 | // 141 | // /** 142 | // */ 143 | // private suspend fun CommandSenderOnMessage<*>.sendArticle(aid: Long) = withHelper { 144 | // val article = Pixivision.getArticle(aid = aid) 145 | // val nodes = mutableListOf<ForwardMessage.Node>() 146 | // getListIllusts(info = article.illusts).collect { illusts -> 147 | // val list = illusts.map { illust -> 148 | // val sender = (contact as? User) ?: (contact as Group).members.random() 149 | // async { 150 | // try { 151 | // ForwardMessage.Node( 152 | // senderId = sender.id, 153 | // senderName = sender.nameCardOrNick, 154 | // time = illust.createAt.toEpochSecond().toInt(), 155 | // message = buildMessageByIllust(illust = illust) 156 | // ) 157 | // } catch (e: Throwable) { 158 | // ForwardMessage.Node( 159 | // senderId = sender.id, 160 | // senderName = sender.nameCardOrNick, 161 | // time = illust.createAt.toEpochSecond().toInt(), 162 | // message = "[${illust.pid}]构建失败 ${e.message.orEmpty()}".toPlainText() 163 | // ) 164 | // } 165 | // 166 | // } 167 | // }.awaitAll() 168 | // 169 | // nodes.addAll(list) 170 | // } 171 | // RawForwardMessage(nodes).render { 172 | // title = "插画特辑" 173 | // preview = listOf(article.title) + article.description.lines() 174 | // summary = "查看特辑的${article.illusts.size}个作品" 175 | // } 176 | // } 177 | //} -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelper.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.event.* 5 | import net.mamoe.mirai.utils.* 6 | import xyz.cssxsh.mirai.pixiv.data.* 7 | import xyz.cssxsh.mirai.pixiv.event.* 8 | import xyz.cssxsh.mirai.pixiv.model.* 9 | import xyz.cssxsh.mirai.pixiv.task.* 10 | import xyz.cssxsh.pixiv.* 11 | import kotlin.coroutines.* 12 | 13 | /** 14 | * Pixiv 助手 15 | * @see PixivHelperPool.helper 16 | */ 17 | public class PixivHelper internal constructor(public val id: Long, parentContext: CoroutineContext) : 18 | CoroutineScope { 19 | 20 | private val logger = MiraiLogger.Factory.create(this::class, identity = "pixiv-helper-${id}") 21 | 22 | override val coroutineContext: CoroutineContext = parentContext + CoroutineName(name = "pixiv-helper-${id}") 23 | 24 | public val client: PixivAuthClient by PixivClientPool 25 | 26 | public val uid: Long? get() = (client as? PixivClientPool.AuthClient)?.uid 27 | 28 | public var link: Boolean by LinkDelegate 29 | 30 | public var tag: Boolean by TagDelegate 31 | 32 | public var attr: Boolean by AttrDelegate 33 | 34 | public var max: Int by MaxDelegate 35 | 36 | public var model: SendModel by ModelDelegate 37 | 38 | private var record: Long = 0 39 | 40 | /** 41 | * @return 返回 true 时取消指令执行 42 | */ 43 | @Synchronized 44 | public fun shake(): Boolean { 45 | val current = System.currentTimeMillis() 46 | return if (current - record > 3000) { 47 | record = current 48 | false 49 | } else { 50 | true 51 | } 52 | } 53 | 54 | private val eros: MutableMap<Long, ArtWorkInfo> = java.util.concurrent.ConcurrentHashMap() 55 | 56 | /**' 57 | * 随机一份色图 58 | */ 59 | public suspend fun ero(sanity: Int, bookmarks: Long): ArtWorkInfo? { 60 | val event = PixivEvent.EroPost(helper = this, sanity = sanity, bookmarks = bookmarks).broadcast() 61 | if (event.isCancelled) { 62 | logger.info { "色图获取被终止" } 63 | return null 64 | } 65 | 66 | for ((pid, arkwotk) in eros) { 67 | if (arkwotk.sanity >= sanity && arkwotk.bookmarks > bookmarks) { 68 | eros.remove(pid) 69 | return arkwotk 70 | } 71 | } 72 | for (info in ArtWorkInfo.random(sanity, bookmarks, EroAgeLimit, EroChunk)) { 73 | eros[info.pid] = info 74 | } 75 | for ((pid, arkwotk) in eros) { 76 | if (arkwotk.sanity >= sanity && arkwotk.bookmarks > bookmarks) { 77 | eros.remove(pid) 78 | return arkwotk 79 | } 80 | } 81 | 82 | return null 83 | } 84 | 85 | /** 86 | * 根据标签获取色图 87 | */ 88 | public suspend fun tag(word: String, bookmarks: Long, fuzzy: Boolean): ArtWorkInfo? { 89 | val event = PixivEvent.TagPost(helper = this, word = word, bookmarks = bookmarks, fuzzy = fuzzy).broadcast() 90 | if (event.isCancelled) { 91 | logger.info { "标签获取被终止" } 92 | return null 93 | } 94 | val list = ArtWorkInfo.tag(word = word, marks = bookmarks, fuzzy = fuzzy, age = TagAgeLimit, limit = EroChunk) 95 | 96 | for (artwork in list) { 97 | if (!artwork.ero) continue 98 | eros[artwork.pid] = artwork 99 | } 100 | if (list.size < EroChunk) { 101 | try { 102 | PixivCacheLoader.cache(task = buildPixivCacheTask { 103 | name = "TAG[${word}]" 104 | flow = client.search(tag = word) 105 | }) 106 | } catch (_: Exception) { 107 | // 108 | } 109 | } 110 | return list.randomOrNull() 111 | } 112 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelperDelegate.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import xyz.cssxsh.mirai.pixiv.data.* 4 | import kotlin.properties.* 5 | import kotlin.reflect.* 6 | 7 | public object LinkDelegate : ReadWriteProperty<PixivHelper, Boolean> { 8 | 9 | override fun setValue(thisRef: PixivHelper, property: KProperty<*>, value: Boolean) { 10 | PixivConfigData.link[thisRef.id] = value 11 | } 12 | 13 | override fun getValue(thisRef: PixivHelper, property: KProperty<*>): Boolean { 14 | return PixivConfigData.link.getOrDefault(thisRef.id, false) 15 | } 16 | } 17 | 18 | public object TagDelegate : ReadWriteProperty<PixivHelper, Boolean> { 19 | 20 | override fun setValue(thisRef: PixivHelper, property: KProperty<*>, value: Boolean) { 21 | PixivConfigData.tag[thisRef.id] = value 22 | } 23 | 24 | override fun getValue(thisRef: PixivHelper, property: KProperty<*>): Boolean { 25 | return PixivConfigData.tag.getOrDefault(thisRef.id, false) 26 | } 27 | } 28 | 29 | public object AttrDelegate : ReadWriteProperty<PixivHelper, Boolean> { 30 | 31 | override fun setValue(thisRef: PixivHelper, property: KProperty<*>, value: Boolean) { 32 | PixivConfigData.attr[thisRef.id] = value 33 | } 34 | 35 | override fun getValue(thisRef: PixivHelper, property: KProperty<*>): Boolean { 36 | return PixivConfigData.attr.getOrDefault(thisRef.id, false) 37 | } 38 | } 39 | 40 | public object MaxDelegate : ReadWriteProperty<PixivHelper, Int> { 41 | 42 | override fun setValue(thisRef: PixivHelper, property: KProperty<*>, value: Int) { 43 | PixivConfigData.max[thisRef.id] = value 44 | } 45 | 46 | override fun getValue(thisRef: PixivHelper, property: KProperty<*>): Int { 47 | return PixivConfigData.max.getOrDefault(thisRef.id, 3) 48 | } 49 | } 50 | 51 | public object ModelDelegate : ReadWriteProperty<PixivHelper, SendModel> { 52 | 53 | override fun setValue(thisRef: PixivHelper, property: KProperty<*>, value: SendModel) { 54 | PixivConfigData.model[thisRef.id] = value 55 | } 56 | 57 | override fun getValue(thisRef: PixivHelper, property: KProperty<*>): SendModel { 58 | return PixivConfigData.model.getOrDefault(thisRef.id, SendModel.Normal) 59 | } 60 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelperDownloader.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import io.ktor.client.network.sockets.* 4 | import io.ktor.http.* 5 | import kotlinx.coroutines.* 6 | import net.mamoe.mirai.utils.* 7 | import xyz.cssxsh.pixiv.* 8 | import xyz.cssxsh.pixiv.tool.* 9 | import java.io.IOException 10 | import java.net.Proxy 11 | 12 | public object PixivHelperDownloader : PixivDownloader(host = PIXIV_HOST, async = PIXIV_DOWNLOAD_ASYNC) { 13 | 14 | private var erros = 0 15 | 16 | override val ignore: suspend (Throwable) -> Boolean = { throwable -> 17 | when (throwable) { 18 | is SocketTimeoutException, 19 | is ConnectTimeoutException -> { 20 | logger.warning { "Download 超时, 已忽略: ${throwable.message}" } 21 | true 22 | } 23 | is IOException -> { 24 | logger.warning { "Download 错误, 已忽略: $throwable" } 25 | delay(++erros * 1000L) 26 | erros-- 27 | true 28 | } 29 | else -> false 30 | } 31 | } 32 | 33 | override val timeout: Long get() = TimeoutDownload 34 | 35 | override val blockSize: Int get() = BlockSize 36 | 37 | @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") 38 | override val proxy: Proxy? by lazy { 39 | if (ProxyDownload.isNotBlank()) { 40 | Url(ProxyDownload).toProxy() 41 | } else { 42 | null 43 | } 44 | } 45 | 46 | override suspend fun download(url: Url): ByteArray { 47 | return super.download( 48 | if (url.host == "i.pximg.net" && ProxyMirror.isNotEmpty()) { 49 | URLBuilder(url).apply { host = ProxyMirror }.build() 50 | } else { 51 | url 52 | } 53 | ) 54 | } 55 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelperGifEncoder.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import com.squareup.gifencoder.* 4 | import io.ktor.http.* 5 | import kotlinx.coroutines.sync.* 6 | import net.mamoe.mirai.utils.* 7 | import xyz.cssxsh.mirai.pixiv.data.* 8 | import xyz.cssxsh.pixiv.apps.* 9 | import xyz.cssxsh.pixiv.tool.* 10 | import java.io.* 11 | 12 | public object PixivHelperGifEncoder : PixivGifEncoder(downloader = PixivHelperDownloader) { 13 | 14 | public override val cache: File get() = UgoiraImagesFolder 15 | 16 | override suspend fun download(url: Url, filename: String): File { 17 | val pid = filename.substringBefore('_').toLong() 18 | return images(pid).resolve(filename).apply { 19 | if (exists().not()) { 20 | if (cache.resolve(filename).exists()) { 21 | cache.resolve(filename).renameTo(this) 22 | } else { 23 | writeBytes(downloader.download(url)) 24 | logger.info { "$filename 下载完成" } 25 | } 26 | } 27 | } 28 | } 29 | 30 | override val quantizer: ColorQuantizer by lazy { instance(PixivGifConfig.quantizer) } 31 | 32 | override val ditherer: Ditherer by lazy { instance(PixivGifConfig.ditherer) } 33 | 34 | override val disposalMethod: DisposalMethod by lazy { PixivGifConfig.disposal } 35 | 36 | // 考虑到GIF编码需要较高性能 37 | private val single = Mutex() 38 | 39 | public suspend fun build(illust: IllustInfo, metadata: UgoiraMetadata, flush: Boolean): File { 40 | metadata.download() 41 | val gif = cache.resolve("${illust.pid}.gif") 42 | return if (flush || gif.exists().not()) { 43 | single.withLock { 44 | with(illust) { 45 | logger.info { 46 | "动图(${pid})<${createAt}>[${user.id}][${title}][${metadata.frames.size}]{${totalBookmarks}}开始编码" 47 | } 48 | } 49 | encode(illust, metadata) 50 | } 51 | } else { 52 | gif 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelperMP4Encoder.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import io.ktor.http.* 4 | import net.mamoe.mirai.utils.* 5 | import org.jcodec.api.awt.AWTSequenceEncoder 6 | import org.jcodec.common.io.NIOUtils 7 | import org.jcodec.common.model.Rational 8 | import xyz.cssxsh.pixiv.apps.* 9 | import xyz.cssxsh.pixiv.tool.* 10 | import java.io.* 11 | import java.util.zip.ZipFile 12 | import javax.imageio.ImageIO 13 | 14 | public object PixivHelperMP4Encoder : PixivGifEncoder(downloader = PixivHelperDownloader) { 15 | 16 | public override val cache: File get() = UgoiraImagesFolder 17 | 18 | override suspend fun download(url: Url, filename: String): File { 19 | val pid = filename.substringBefore('_').toLong() 20 | return images(pid).resolve(filename).apply { 21 | if (exists().not()) { 22 | if (PixivHelperGifEncoder.cache.resolve(filename).exists()) { 23 | PixivHelperGifEncoder.cache.resolve(filename).renameTo(this) 24 | } else { 25 | writeBytes(downloader.download(url)) 26 | logger.info { "$filename 下载完成" } 27 | } 28 | } 29 | } 30 | } 31 | 32 | override suspend fun encode(illust: IllustInfo, metadata: UgoiraMetadata, loop: Int): File { 33 | val pack = metadata.download() 34 | val mp4 = PixivHelperGifEncoder.cache.resolve("${illust.pid}.mp4") 35 | if (mp4.exists().not()) { 36 | with(illust) { 37 | logger.info { 38 | "动画(${pid})<${createAt}>[${user.id}][${title}][${metadata.frames.size}]{${totalBookmarks}}开始编码" 39 | } 40 | } 41 | NIOUtils.writableChannel(mp4).use { out -> 42 | val encoder = AWTSequenceEncoder(out, Rational.R(100, 1)) 43 | ZipFile(pack).use { zip -> 44 | for (frame in metadata.frames) { 45 | val entry = zip.getEntry(frame.file) ?: throw FileNotFoundException(frame.file) 46 | val image = ImageIO.read(zip.getInputStream(entry)) 47 | repeat((frame.delay / 10).toInt()) { 48 | encoder.encodeImage(image) 49 | } 50 | } 51 | } 52 | encoder.finish() 53 | } 54 | } 55 | return mp4 56 | } 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelperPlugin.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register 4 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.unregister 5 | import net.mamoe.mirai.console.extension.* 6 | import net.mamoe.mirai.console.permission.* 7 | import net.mamoe.mirai.console.plugin.jvm.* 8 | import net.mamoe.mirai.event.* 9 | import xyz.cssxsh.mirai.pixiv.command.* 10 | import xyz.cssxsh.mirai.pixiv.data.* 11 | 12 | public object PixivHelperPlugin : KotlinPlugin( 13 | JvmPluginDescription(id = "xyz.cssxsh.mirai.plugin.pixiv-helper", version = "2.0.0") { 14 | name("pixiv-helper") 15 | author("cssxsh") 16 | 17 | dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", ">= 2.3.3", false) 18 | dependsOn("xyz.cssxsh.mirai.plugin.mirai-selenium-plugin", true) 19 | dependsOn("xyz.cssxsh.mirai.plugin.mirai-skia-plugin", true) 20 | dependsOn("xyz.cssxsh.mirai.plugin.arknights-helper", true) 21 | } 22 | ) { 23 | 24 | private fun JvmPlugin.registerPermission(name: String, description: String): Permission { 25 | return PermissionService.INSTANCE.register(permissionId(name), description, parentPermission) 26 | } 27 | 28 | override fun PluginComponentStorage.onLoad() { 29 | runAfterStartup { 30 | PixivScheduler.start() 31 | } 32 | } 33 | 34 | override fun onEnable() { 35 | PixivHelperSettings.reload() 36 | PixivConfigData.reload() 37 | PixivGifConfig.reload() 38 | PixivAuthData.reload() 39 | PixivTaskData.reload() 40 | ImageSearchConfig.reload() 41 | 42 | // Command 43 | for (command in PixivHelperCommand) { 44 | command.register() 45 | } 46 | 47 | try { 48 | xyz.cssxsh.mirai.arknights.ArknightsHelperPlugin.logger 49 | ArknightsEroCommand.register() 50 | } catch (_: NoClassDefFoundError) { 51 | // 52 | } 53 | 54 | initConfiguration() 55 | 56 | PixivEventListener.paserPermission = registerPermission("url", "PIXIV URL 解析") 57 | PixivEventListener.registerTo(globalEventChannel()) 58 | } 59 | 60 | override fun onDisable() { 61 | for (command in PixivHelperCommand) { 62 | command.unregister() 63 | } 64 | 65 | try { 66 | xyz.cssxsh.mirai.arknights.ArknightsHelperPlugin.logger 67 | ArknightsEroCommand.unregister() 68 | } catch (_: NoClassDefFoundError) { 69 | // 70 | } 71 | 72 | PixivEventListener.cancelAll() 73 | 74 | PixivScheduler.stop() 75 | } 76 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivHelperPool.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.contact.* 5 | import net.mamoe.mirai.utils.* 6 | import kotlin.coroutines.* 7 | import kotlin.properties.* 8 | import kotlin.reflect.* 9 | 10 | public object PixivHelperPool : ReadOnlyProperty<Contact, PixivHelper>, CoroutineScope { 11 | private val logger by lazy { MiraiLogger.Factory.create(this::class, identity = "pixiv-helper-pool") } 12 | 13 | override val coroutineContext: CoroutineContext = 14 | CoroutineName(name = "pixiv-helper-pool") + SupervisorJob() + CoroutineExceptionHandler { context, throwable -> 15 | logger.warning({ "$throwable in $context" }, throwable) 16 | } 17 | 18 | private val helpers: MutableMap<Long, PixivHelper> = java.util.concurrent.ConcurrentHashMap() 19 | 20 | override fun getValue(thisRef: Contact, property: KProperty<*>): PixivHelper { 21 | return helper(contact = thisRef) 22 | } 23 | 24 | @Synchronized 25 | public fun helper(contact: Contact): PixivHelper { 26 | return helpers.getOrPut(contact.id) { 27 | PixivHelper(id = contact.id, parentContext = coroutineContext) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivProperty.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.contact.Contact 5 | import net.mamoe.mirai.utils.* 6 | import xyz.cssxsh.mirai.pixiv.data.* 7 | import xyz.cssxsh.pixiv.* 8 | import java.io.* 9 | 10 | // region PROPERTY 11 | 12 | internal const val CACHE_FOLDER_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.cache" 13 | 14 | internal const val BACKUP_FOLDER_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.backup" 15 | 16 | internal const val TEMP_FOLDER_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.temp" 17 | 18 | internal const val ERO_CHUNK_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.ero.chunk" 19 | 20 | internal const val ERO_UP_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.ero.up" 21 | 22 | internal const val ERO_SFW_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.ero.sfw" 23 | 24 | internal const val ERO_STANDARD_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.ero.standard" 25 | 26 | internal const val TAG_SFW_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.tag.sfw" 27 | 28 | internal const val TIMEOUT_DOWNLOAD_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.timeout.download" 29 | 30 | internal const val PROXY_API_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.proxy.api" 31 | 32 | internal const val PROXY_DOWNLOAD_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.proxy.download" 33 | 34 | internal const val PROXY_MIRROR_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.proxy.mirror" 35 | 36 | internal const val BLOCK_SIZE_PROPERTY = "xyz.cssxsh.mirai.plugin.pixiv.block" 37 | 38 | // endregion 39 | 40 | /** 41 | * 1. [PixivHelperPlugin.logger] 42 | */ 43 | internal val logger by lazy { 44 | try { 45 | PixivHelperPlugin.logger 46 | } catch (_: ExceptionInInitializerError) { 47 | MiraiLogger.Factory.create(PixivHelper::class) 48 | } 49 | } 50 | 51 | /** 52 | * 1. [CACHE_FOLDER_PROPERTY] 53 | * 2. [PixivHelperSettings.cacheFolder] 54 | */ 55 | internal val CacheFolder by lazy { 56 | val path = System.getProperty(CACHE_FOLDER_PROPERTY) 57 | if (path.isNullOrBlank()) PixivHelperSettings.cacheFolder else File(path) 58 | } 59 | 60 | /** 61 | * 1. [BACKUP_FOLDER_PROPERTY] 62 | * 2. [PixivHelperSettings.backupFolder] 63 | */ 64 | internal val BackupFolder by lazy { 65 | val path = System.getProperty(BACKUP_FOLDER_PROPERTY) 66 | if (path.isNullOrBlank()) PixivHelperSettings.backupFolder else File(path) 67 | } 68 | 69 | /** 70 | * 1. [TEMP_FOLDER_PROPERTY] 71 | * 2. [PixivHelperSettings.tempFolder] 72 | */ 73 | internal val TempFolder by lazy { 74 | val path = System.getProperty(TEMP_FOLDER_PROPERTY) 75 | if (path.isNullOrBlank()) PixivHelperSettings.tempFolder else File(path) 76 | } 77 | 78 | /** 79 | * Task连续发送间隔时间 80 | * 1. [PixivConfigData.interval] 81 | */ 82 | internal val TaskSendInterval by PixivConfigData::interval 83 | 84 | /** 85 | * Task通过转发发送 86 | * 1. [PixivConfigData.interval] 87 | */ 88 | internal val TaskForward by PixivConfigData::forward 89 | 90 | /** 91 | * TODO TaskConut by PixivConfigData 92 | */ 93 | internal const val TaskConut = 10 94 | 95 | /** 96 | * 涩图防重复间隔 97 | * 1. [ERO_CHUNK_PROPERTY] 98 | * 2. [PixivHelperSettings.eroChunk] 99 | */ 100 | internal val EroChunk by lazy { 101 | System.getProperty(ERO_CHUNK_PROPERTY)?.toInt() ?: PixivHelperSettings.eroChunk 102 | } 103 | 104 | /** 105 | * 涩图提高收藏数时间 106 | * 1. [ERO_UP_PROPERTY] 107 | * 2. [PixivHelperSettings.eroUpExpire] 108 | */ 109 | internal val EroUpExpire by lazy { 110 | System.getProperty(ERO_UP_PROPERTY)?.toLong() ?: PixivHelperSettings.eroUpExpire 111 | } 112 | 113 | /** 114 | * 涩图标准 115 | * 1. [ERO_STANDARD_PROPERTY] 116 | * 2. [PixivHelperSettings] 117 | */ 118 | internal val EroStandard by lazy { 119 | System.getProperty(ERO_STANDARD_PROPERTY)?.let { PixivJson.decodeFromString(EroStandardData.serializer(), it) } 120 | ?: PixivHelperSettings 121 | } 122 | 123 | /** 124 | * TAG 年龄限制 125 | * 1. [TAG_SFW_PROPERTY] 126 | * 2. [PixivHelperSettings.tagSFW] 127 | */ 128 | internal val TagAgeLimit by lazy { 129 | if (System.getProperty(TAG_SFW_PROPERTY)?.toBoolean() ?: PixivHelperSettings.tagSFW) { 130 | AgeLimit.ALL 131 | } else { 132 | AgeLimit.R18G 133 | } 134 | } 135 | 136 | /** 137 | * ERO 年龄限制 138 | * 1. [ERO_SFW_PROPERTY] 139 | * 2. [PixivHelperSettings.eroSFW] 140 | */ 141 | internal val EroAgeLimit by lazy { 142 | if (System.getProperty(ERO_SFW_PROPERTY)?.toBoolean() ?: PixivHelperSettings.eroSFW) { 143 | AgeLimit.ALL 144 | } else { 145 | AgeLimit.R18G 146 | } 147 | } 148 | 149 | /** 150 | * DOWNLOAD超时时间, 单位ms 151 | * 1. [TIMEOUT_DOWNLOAD_PROPERTY] 152 | * 2. [PixivHelperSettings.timeoutDownload] 153 | */ 154 | internal val TimeoutDownload by lazy { 155 | System.getProperty(TIMEOUT_DOWNLOAD_PROPERTY)?.toLong() ?: PixivHelperSettings.timeoutDownload 156 | } 157 | 158 | /** 159 | * API代理 160 | * 1. [PROXY_API_PROPERTY] 161 | * 2. [PixivHelperSettings.proxyApi] 162 | */ 163 | internal val ProxyApi by lazy { 164 | System.getProperty(PROXY_API_PROPERTY) ?: PixivHelperSettings.proxyApi 165 | } 166 | 167 | /** 168 | * DOWNLOAD代理 169 | * 1. [PROXY_DOWNLOAD_PROPERTY] 170 | * 2. [PixivHelperSettings.proxyDownload] 171 | */ 172 | internal val ProxyDownload by lazy { 173 | System.getProperty(PROXY_DOWNLOAD_PROPERTY) ?: PixivHelperSettings.proxyDownload 174 | } 175 | 176 | /** 177 | * MIRROR代理 178 | * 1. [PROXY_MIRROR_PROPERTY] 179 | * 2. [PixivHelperSettings.pximg] 180 | */ 181 | internal val ProxyMirror by lazy { 182 | System.getProperty(PROXY_MIRROR_PROPERTY) ?: PixivHelperSettings.pximg 183 | } 184 | 185 | /** 186 | * DOWNLOAD分块大小, 单位B 187 | * 1. [BLOCK_SIZE_PROPERTY] 188 | * 2. [PixivHelperSettings.blockSize] 189 | */ 190 | internal val BlockSize by lazy { 191 | System.getProperty(BLOCK_SIZE_PROPERTY)?.toInt() ?: PixivHelperSettings.blockSize 192 | } 193 | 194 | // region FOLDER 195 | 196 | /** 197 | * 用户文件保存目录 198 | */ 199 | internal val ProfileFolder: File get() = CacheFolder.resolve("profile") 200 | 201 | /** 202 | * 特辑文件保存目录 203 | */ 204 | internal val ArticleFolder: File get() = CacheFolder.resolve("article") 205 | 206 | /** 207 | * 图片目录 208 | */ 209 | internal fun images(pid: Long): File { 210 | return CacheFolder 211 | .resolve("%03d______".format(pid / 1_000_000)) 212 | .resolve("%06d___".format(pid / 1_000)) 213 | .resolve("$pid") 214 | } 215 | 216 | /** 217 | * GIF目录 218 | */ 219 | internal val UgoiraImagesFolder: File get() = TempFolder.resolve("gif") 220 | 221 | /** 222 | * OTHER目录 223 | */ 224 | internal val OtherImagesFolder: File get() = TempFolder.resolve("other") 225 | 226 | /** 227 | * EXISTS目录 228 | */ 229 | internal val ExistsImagesFolder: File get() = TempFolder.resolve("exists") 230 | 231 | /** 232 | * 图片 JSON 233 | */ 234 | internal fun illust(pid: Long) = images(pid).resolve("${pid}.json") 235 | 236 | /** 237 | * 动图 JSON 238 | */ 239 | internal fun ugoira(pid: Long) = images(pid).resolve("${pid}.ugoira.json") 240 | 241 | // endregion 242 | 243 | /** 244 | * 从 [Contact] 上下文得到 [PixivHelper] 245 | */ 246 | public val Contact.helper: PixivHelper by PixivHelperPool 247 | 248 | /** 249 | * 从 [CommandSender] 上下文得到 [PixivClientPool.AuthClient] 250 | */ 251 | public fun CommandSender.client(): PixivClientPool.AuthClient { 252 | return when { 253 | isUser() -> PixivClientPool.get(id = subject.id) 254 | else -> PixivClientPool.console() 255 | } ?: throw IllegalArgumentException("未绑定 PIXIV 账号") 256 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/PixivSkikoGifEncoder.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv 2 | 3 | import io.ktor.http.* 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.sync.* 6 | import net.mamoe.mirai.utils.* 7 | import org.jetbrains.skia.* 8 | import xyz.cssxsh.gif.* 9 | import xyz.cssxsh.pixiv.apps.* 10 | import xyz.cssxsh.pixiv.tool.* 11 | import java.io.* 12 | import java.util.zip.* 13 | 14 | public object PixivSkikoGifEncoder : PixivGifEncoder(downloader = PixivHelperDownloader) { 15 | 16 | public override val cache: File get() = UgoiraImagesFolder 17 | 18 | override suspend fun download(url: Url, filename: String): File { 19 | val pid = filename.substringBefore('_').toLong() 20 | return images(pid).resolve(filename).apply { 21 | if (exists().not()) { 22 | if (cache.resolve(filename).exists()) { 23 | cache.resolve(filename).renameTo(this) 24 | } else { 25 | writeBytes(downloader.download(url)) 26 | logger.info { "$filename 下载完成" } 27 | } 28 | } 29 | } 30 | } 31 | 32 | override suspend fun encode(illust: IllustInfo, metadata: UgoiraMetadata, loop: Int): File { 33 | val file = metadata.download() 34 | val gif = cache.resolve("${illust.pid}.gif") 35 | val temp = cache.resolve("${illust.pid}.temp") 36 | runInterruptible(Dispatchers.IO) { 37 | val zip = ZipFile(file) 38 | val first = 39 | Image.makeFromEncoded(zip.getInputStream(zip.getEntry(metadata.frames.first().file)).readBytes()) 40 | val encoder = Encoder(temp, first.width, first.height) 41 | encoder.repeat = if (loop > 0) loop else -1 42 | 43 | for (frame in metadata.frames) { 44 | val image = Image.makeFromEncoded(zip.getInputStream(zip.getEntry(frame.file)).readBytes()) 45 | encoder.writeImage( 46 | image = image, 47 | mills = frame.delay.toInt(), 48 | disposal = AnimationDisposalMode.RESTORE_PREVIOUS 49 | ) 50 | image.close() 51 | } 52 | 53 | zip.close() 54 | encoder.close() 55 | } 56 | temp.renameTo(gif) 57 | return gif 58 | } 59 | 60 | private val single = Mutex() 61 | 62 | public suspend fun build(illust: IllustInfo, metadata: UgoiraMetadata, flush: Boolean): File { 63 | metadata.download() 64 | val gif = cache.resolve("${illust.pid}.gif") 65 | Library.staticLoad() 66 | return if (flush || gif.exists().not()) { 67 | gif.delete() 68 | single.withLock { 69 | with(illust) { 70 | logger.info { 71 | "动图(${pid})<${createAt}>[${user.id}][${title}][${metadata.frames.size}]{${totalBookmarks}}开始编码" 72 | } 73 | } 74 | encode(illust, metadata) 75 | } 76 | } else { 77 | gif 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/ArknightsEroCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.console.command.descriptor.* 6 | import net.mamoe.mirai.console.util.* 7 | import net.mamoe.mirai.contact.* 8 | import net.mamoe.mirai.event.events.* 9 | import net.mamoe.mirai.message.data.* 10 | import net.mamoe.mirai.utils.warning 11 | import xyz.cssxsh.mirai.arknights.* 12 | import xyz.cssxsh.mirai.pixiv.* 13 | import xyz.cssxsh.mirai.pixiv.model.* 14 | import xyz.cssxsh.pixiv.* 15 | import java.util.* 16 | 17 | public object ArknightsEroCommand : SimpleCommand( 18 | owner = PixivHelperPlugin, 19 | "ark-ero", "方舟色图", 20 | description = "PIXIV色图指令" 21 | ) { 22 | 23 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class) 24 | override val prefixOptional: Boolean = true 25 | 26 | private val cache: MutableMap<Long, ArtWorkInfo> = WeakHashMap() 27 | 28 | private fun push(): List<ArtWorkInfo> { 29 | val list = ArtWorkInfo.tag(word = "Arknights", marks = 10_000L, fuzzy = false, age = AgeLimit.ALL, limit = 100) 30 | for (artwork in list) { 31 | cache[artwork.pid] = artwork 32 | } 33 | 34 | return list 35 | } 36 | 37 | @Handler 38 | @Suppress("INVISIBLE_MEMBER") 39 | public suspend fun UserCommandSender.handle() { 40 | try { 41 | if (user.coin > 6_000) { 42 | user.coin -= 6_000 43 | 44 | val info = if (cache.isEmpty()) { 45 | val list = push() 46 | list.random() 47 | } else { 48 | cache.values.random() 49 | } 50 | cache.remove(info.pid) 51 | 52 | if (cache.size < 30) { 53 | launch { 54 | push() 55 | } 56 | } 57 | 58 | sendArtwork(info) 59 | } else { 60 | sendMessage("合成玉不足, 可以通过答题获取") 61 | } 62 | } catch (_: NoClassDefFoundError) { 63 | logger.warning { "请安装 https://github.com/cssxsh/arknights-helper" } 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivBoomCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import kotlinx.coroutines.sync.* 6 | import net.mamoe.mirai.console.command.* 7 | import net.mamoe.mirai.console.command.descriptor.* 8 | import net.mamoe.mirai.console.util.* 9 | import net.mamoe.mirai.contact.* 10 | import net.mamoe.mirai.message.data.* 11 | import net.mamoe.mirai.utils.* 12 | import xyz.cssxsh.mirai.pixiv.* 13 | import xyz.cssxsh.mirai.pixiv.model.* 14 | import xyz.cssxsh.mirai.pixiv.task.* 15 | import xyz.cssxsh.pixiv.* 16 | import xyz.cssxsh.pixiv.apps.* 17 | 18 | public object PixivBoomCommand : SimpleCommand( 19 | owner = PixivHelperPlugin, 20 | "boom", "射爆", "社保", "[炸弹]", 21 | description = "PIXIV色图爆炸指令" 22 | ), PixivHelperCommand { 23 | 24 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class) 25 | override val prefixOptional: Boolean = true 26 | 27 | @Handler 28 | public suspend fun UserCommandSender.handle(limit: Int = EroChunk, word: String = ""): Unit = withHelper { 29 | val artworks = when { 30 | word.isEmpty() -> { 31 | ArtWorkInfo.random(level = 0, marks = 0, age = EroAgeLimit, limit = limit) 32 | } 33 | RankMode.values().any { it.name == word.uppercase() } -> { 34 | val mode = RankMode.valueOf(word.uppercase()) 35 | val result = ArrayList<IllustInfo>(limit) 36 | val mutex = Mutex() 37 | mutex.lock() 38 | 39 | PixivCacheLoader.cache(task = buildPixivCacheTask { 40 | name = "RANK-$mode-${subject.id}" 41 | flow = client.rank(mode).onEach { result.addAll(it) } 42 | }) { _, _ -> 43 | mutex.unlock() 44 | } 45 | 46 | mutex.withLock { 47 | buildList(limit) { 48 | for (illust in result) { 49 | if (result.size >= limit) break 50 | add(illust.toArtWorkInfo()) 51 | } 52 | } 53 | } 54 | } 55 | word.toLongOrNull() != null -> { 56 | ArtWorkInfo.user(uid = word.toLong()).shuffled().take(limit) 57 | } 58 | else -> { 59 | ArtWorkInfo.tag(word = word, marks = EroStandard.marks, fuzzy = false, age = TagAgeLimit, limit = limit) 60 | } 61 | } 62 | 63 | if (artworks.isEmpty()) return@withHelper "列表为空".toPlainText() 64 | 65 | val current = System.currentTimeMillis() 66 | 67 | sendMessage("开始将${artworks.size}个作品合成转发消息,请稍后...") 68 | 69 | val list = artworks.sortedBy { it.pid }.map { artwork -> 70 | val sender = (subject as? User) ?: (subject as Group).members.random() 71 | 72 | async { 73 | try { 74 | val illust = loadIllustInfo(pid = artwork.pid, flush = false, client = client) 75 | ForwardMessage.Node( 76 | senderId = sender.id, 77 | senderName = sender.nameCardOrNick, 78 | time = illust.createAt.toEpochSecond().toInt(), 79 | message = buildIllustMessage(illust = illust, contact = subject) 80 | ) 81 | } catch (cause: Exception) { 82 | logger.warning({ "BOOM BUILD 错误" }, cause) 83 | ForwardMessage.Node( 84 | senderId = sender.id, 85 | senderName = sender.nameCardOrNick, 86 | time = artwork.created.toInt(), 87 | message = "[${artwork.pid}]构建失败 ${cause.message}".toPlainText() 88 | ) 89 | } 90 | } 91 | }.awaitAll() 92 | 93 | val millis = System.currentTimeMillis() - current 94 | 95 | logger.info { "BOOM BUILD ${word.ifEmpty { "RANDOM" }} ${list.size} in ${millis}ms 完成" } 96 | 97 | RawForwardMessage(list).render { 98 | title = "${word.ifEmpty { "随机" }}的快递" 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivCacheCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import net.mamoe.mirai.console.command.* 6 | import net.mamoe.mirai.utils.* 7 | import xyz.cssxsh.mirai.pixiv.* 8 | import xyz.cssxsh.mirai.pixiv.model.* 9 | import xyz.cssxsh.mirai.pixiv.task.* 10 | import xyz.cssxsh.pixiv.* 11 | import xyz.cssxsh.pixiv.apps.* 12 | import xyz.cssxsh.pixiv.exception.* 13 | import java.time.* 14 | 15 | public object PixivCacheCommand : CompositeCommand( 16 | owner = PixivHelperPlugin, 17 | "cache", 18 | description = "PIXIV缓存指令", 19 | overrideContext = PixivCommandArgumentContext 20 | ), PixivHelperCommand { 21 | 22 | private suspend fun CommandSender.cache(block: suspend PixivTaskBuilder.() -> Unit) { 23 | if ((this as? UserCommandSender)?.subject?.helper?.shake() == true) return 24 | try { 25 | val task = PixivTaskBuilder().apply { block() }.build() 26 | val scope = this 27 | PixivCacheLoader.cache(task = task) { _, cause -> 28 | scope.launch { 29 | when (cause) { 30 | null -> sendMessage("${task.name} 缓存完成") 31 | is CancellationException -> sendMessage("${task.name} 缓存被终止") 32 | else -> sendMessage("${task.name} 缓存出现异常") 33 | } 34 | } 35 | } 36 | sendMessage("任务 ${task.name} 已添加") 37 | } catch (cause: Exception) { 38 | sendMessage("任务添加失败, ${cause.message}") 39 | } 40 | } 41 | 42 | @SubCommand 43 | @Description("缓存关注的推送") 44 | public suspend fun CommandSender.follow(): Unit = cache { 45 | val client = client() 46 | name = "FOLLOW(${client.uid})" 47 | write = false 48 | flow = client.follow() 49 | } 50 | 51 | @SubCommand 52 | @Description("缓存指定排行榜信息") 53 | public suspend fun CommandSender.rank(mode: RankMode, date: LocalDate? = null): Unit = cache { 54 | val client = PixivClientPool.free() 55 | name = "RANK[${mode.name}](${date ?: "now"})" 56 | flow = client.rank(mode, date) 57 | } 58 | 59 | @SubCommand 60 | @Description("缓存月榜作品") 61 | public suspend fun CommandSender.month(start: LocalDate, end: LocalDate): Unit = cache { 62 | check(start <= end) { "start 要在 end 之前" } 63 | name = "MONTH(${start}~${end})" 64 | write = false 65 | flow = flow { 66 | for (date in start..end) { 67 | val free = try { 68 | PixivClientPool.free() 69 | } catch (cause: NoSuchElementException) { 70 | throw CancellationException("MONTH(${date})任务终止", cause) 71 | } 72 | emitAll(free.rank(RankMode.MONTH, date)) 73 | } 74 | } 75 | } 76 | 77 | @SubCommand 78 | @Description("缓存NaviRank榜作品") 79 | public suspend fun CommandSender.navirank(year: Year? = null): Unit = cache { 80 | name = if (year != null) "NAVIRANK[$year]" else "NAVIRANK" 81 | flow = PixivClientPool.free().navirank(year = year) 82 | } 83 | 84 | @SubCommand 85 | @Description("从推荐画师的预览中缓存色图作品") 86 | public suspend fun CommandSender.recommended(): Unit = cache { 87 | val client = client() 88 | name = "RECOMMENDED(${client.uid})" 89 | flow = client.recommended() 90 | } 91 | 92 | @SubCommand 93 | @Description("从指定用户的收藏中缓存色图作品") 94 | public suspend fun CommandSender.bookmarks(uid: Long): Unit = cache { 95 | name = "BOOKMARKS(${uid})" 96 | flow = PixivClientPool.free().bookmarks(uid = uid) 97 | } 98 | 99 | @SubCommand 100 | @Description("缓存关注画师作品") 101 | public suspend fun CommandSender.following(flush: Boolean = false): Unit = cache { 102 | val client = client() 103 | val detail = client.userDetail(uid = client.uid) 104 | var index = 0 105 | name = "FOLLOW_ALL(${detail.user.id})" 106 | write = false 107 | flow = client.preview(detail = detail).transform { previews -> 108 | for (preview in previews) { 109 | index++ 110 | if (flush || Twitter[preview.user.id].isEmpty()) { 111 | val author = try { 112 | client.userDetail(uid = preview.user.id).apply { twitter() } 113 | } catch (cause: AppApiException) { 114 | logger.warning({ "${index}.FOLLOW_ALL(${preview.user.id})加载失败" }, cause) 115 | continue 116 | } 117 | val total = author.profile.totalArtwork 118 | if (total == 0L) continue 119 | val count = author.user.count() 120 | if (total - count > preview.illusts.size || flush) { 121 | val free = try { 122 | PixivClientPool.free() 123 | } catch (cause: NoSuchElementException) { 124 | throw CancellationException("FOLLOW_ALL(${author.user.id})任务终止", cause) 125 | } 126 | logger.info { "${index}.FOLLOW_ALL(${author.user.id})[${total}]尝试缓存作品" } 127 | emitAll(free.user(detail = author)) 128 | } else { 129 | logger.info { "${index}.FOLLOW_ALL(${author.user.id})[${total}]有${preview.illusts.size}个作品尝试缓存" } 130 | emit(preview.illusts) 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | @SubCommand("fwm") 138 | @Description("缓存关注画师收藏") 139 | public suspend fun CommandSender.followingWithMarks(jump: Long = 0): Unit = cache { 140 | val client = client() 141 | val detail = client.userDetail(uid = client.uid) 142 | var index = 0 143 | name = "FOLLOW_MARKS(${detail.user.id})" 144 | write = false 145 | flow = client.preview(detail = detail, start = jump).transform { previews -> 146 | for (preview in previews) { 147 | index++ 148 | val free = try { 149 | PixivClientPool.free() 150 | } catch (cause: NoSuchElementException) { 151 | throw CancellationException("FOLLOW_MARKS(${preview.user.id})任务终止", cause) 152 | } 153 | logger.info { "${index}.FOLLOW_MARKS(${preview.user.id})尝试缓存收藏" } 154 | try { 155 | var cached = 0 156 | free.bookmarks(uid = preview.user.id).collect { page -> 157 | if (page.all { illust(pid = it.pid).exists() }) cached++ else cached = 0 158 | emit(page) 159 | 160 | if (cached >= 3) throw IllegalStateException("${index}.FOLLOW_MARKS(${preview.user.id}) cached") 161 | } 162 | } catch (_: IllegalStateException) { 163 | logger.info { "${index}.FOLLOW_MARKS(${preview.user.id}) 跳过" } 164 | } 165 | } 166 | } 167 | } 168 | 169 | @SubCommand 170 | @Description("缓存指定画师作品") 171 | public suspend fun CommandSender.user(uid: Long): Unit = cache { 172 | val client = PixivClientPool.free() 173 | val detail = client.userDetail(uid = uid).apply { twitter() } 174 | name = "USER(${uid})" 175 | flow = client.user(detail = detail) 176 | } 177 | 178 | @SubCommand 179 | @Description("缓存搜索TAG得到的作品") 180 | public suspend fun CommandSender.tag(word: String): Unit = cache { 181 | val client = client() 182 | name = "TAG($word)" 183 | flow = client.search(tag = word) 184 | } 185 | 186 | @SubCommand 187 | @Description("缓存未缓存的色图作品") 188 | public suspend fun CommandSender.nocache(ugoira: Boolean = false): Unit = supervisorScope { 189 | for (range in ALL_RANGE) { 190 | if (isActive.not()) break 191 | val artworks = ArtWorkInfo.nocache(range) 192 | if (artworks.isEmpty()) continue 193 | logger.info { "NOCACHE(${range})共${artworks.size}个ArtWork需要Cache" } 194 | 195 | val jobs = ArrayList<Deferred<*>>(artworks.size) 196 | for (artwork in artworks) { 197 | if (!ugoira && artwork.type == WorkContentType.UGOIRA.ordinal) continue 198 | try { 199 | val illust = loadIllustInfo(pid = artwork.pid, flush = false) 200 | jobs.add(async { 201 | when (illust.type) { 202 | WorkContentType.ILLUST -> PixivCacheLoader.images(illust = illust) 203 | WorkContentType.UGOIRA -> PixivCacheLoader.ugoira(illust = illust) 204 | WorkContentType.MANGA -> Unit 205 | } 206 | }) 207 | } catch (cause: AppApiException) { 208 | if (DELETE_REGEX in cause.message) { 209 | ArtWorkInfo.delete(pid = artwork.pid, comment = cause.message) 210 | } else { 211 | logger.warning({ "NOCACHE 加载作品(${artwork.pid})失败" }, cause) 212 | } 213 | } catch (cause: RestrictException) { 214 | ArtWorkInfo.delete(pid = artwork.pid, comment = cause.message) 215 | logger.warning { "NOCACHE 加载作品失败 ${cause.illust}" } 216 | } catch (cause: Exception) { 217 | logger.warning({ "NOCACHE 加载作品(${artwork.pid})失败" }, cause) 218 | } 219 | } 220 | 221 | try { 222 | jobs.awaitAll() 223 | } catch (_: CancellationException) { 224 | // 225 | } 226 | 227 | logger.info { "$range Cache 完毕" } 228 | } 229 | 230 | sendMessage(message = "Cache 完毕") 231 | } 232 | 233 | @SubCommand 234 | @Description("缓存色图画师的作品") 235 | public suspend fun CommandSender.ero(range: LongRange = 3..PAGE_SIZE): Unit = cache { 236 | var index = 0 237 | name = "ERO" 238 | write = false 239 | flow = PixivClientPool.free().ero(range = range).transform { (record, author) -> 240 | index++ 241 | val total = author.profile.totalArtwork 242 | if (total == 0L) return@transform 243 | val free = try { 244 | PixivClientPool.free() 245 | } catch (cause: NoSuchElementException) { 246 | throw CancellationException("${index}.ERO(${author.user.id})任务终止", cause) 247 | } 248 | if (total > record.count) { 249 | logger.info { "${index}.ERO(${author.user.id})[${author.user.name}]有${total}个作品尝试缓存" } 250 | emitAll(free.user(detail = author)) 251 | delay(total.coerceAtLeast(15_000)) 252 | } 253 | } 254 | } 255 | 256 | @SubCommand("ewm") 257 | @Description("缓存色图画师的收藏") 258 | public suspend fun CommandSender.eroWithMarks(range: LongRange = PAGE_SIZE..Int.MAX_VALUE): Unit = cache { 259 | var index = 0 260 | name = "ERO_WITH_MARKS" 261 | write = false 262 | flow = PixivClientPool.free().ero(range = range).transform { (_, author) -> 263 | index++ 264 | val total = author.profile.totalIllustBookmarksPublic 265 | if (total == 0L) return@transform 266 | val free = try { 267 | PixivClientPool.free() 268 | } catch (cause: NoSuchElementException) { 269 | throw CancellationException("${index}.ERO_WITH_MARKS(${author.user.id})任务终止", cause) 270 | } 271 | logger.info { "${index}.ERO_WITH_MARKS(${author.user.id})[${author.user.name}]有${total}个收藏尝试缓存" } 272 | try { 273 | var cached = 0 274 | free.bookmarks(uid = author.user.id).collect { page -> 275 | if (page.all { illust(pid = it.pid).exists() }) cached++ else cached = 0 276 | emit(page) 277 | 278 | if (cached >= 3) throw IllegalStateException("${index}.ERO_WITH_MARKS(${author.user.id}) cached") 279 | } 280 | } catch (_: IllegalStateException) { 281 | logger.info { "${index}.ERO_WITH_MARKS(${author.user.id}) 跳过" } 282 | } 283 | delay(total.coerceAtLeast(15_000)) 284 | } 285 | } 286 | 287 | @SubCommand 288 | @Description("缓存搜索记录") 289 | public suspend fun CommandSender.search(): Unit = cache { 290 | val records = runInterruptible(Dispatchers.IO) { 291 | PixivSearchResult.noCached() 292 | } 293 | name = "SEARCH" 294 | flow = PixivClientPool.free().illusts(targets = records.mapTo(HashSet()) { it.pid }) 295 | // "搜索结果有${list.size}个作品需要缓存" 296 | } 297 | 298 | @SubCommand 299 | @Description("缓存任务详情") 300 | public suspend fun CommandSender.detail() { 301 | sendMessage(message = PixivCacheLoader.detail().ifEmpty { "任务列表为空" }) 302 | } 303 | 304 | @SubCommand 305 | @Description("停止缓存任务") 306 | public suspend fun CommandSender.stop(name: String) { 307 | val message = try { 308 | PixivCacheLoader.stop(name = name) 309 | "任务 $name 将终止" 310 | } catch (cause: Exception) { 311 | cause.message ?: cause.toString() 312 | } 313 | 314 | sendMessage(message = message) 315 | } 316 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivCommandArgumentContext.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import com.cronutils.model.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.console.command.descriptor.* 6 | import xyz.cssxsh.mirai.pixiv.task.* 7 | import java.time.* 8 | import kotlin.reflect.* 9 | 10 | public class RawValueArgumentParser<T : Any>(private val kClass: KClass<T>, public val parse: (raw: String) -> T) : 11 | AbstractCommandValueArgumentParser<T>() { 12 | override fun parse(raw: String, sender: CommandSender): T { 13 | return try { 14 | parse(raw) 15 | } catch (cause: Exception) { 16 | illegalArgument("无法解析 $raw 为${kClass.simpleName}", cause) 17 | } 18 | } 19 | } 20 | 21 | 22 | internal val RANGE_REGEX = """(\d+)(?:\.{2,4}|-|~)(\d+)""".toRegex() 23 | 24 | public val PixivCommandArgumentContext: CommandArgumentContext = buildCommandArgumentContext { 25 | LongRange::class with RawValueArgumentParser(LongRange::class) { raw -> 26 | requireNotNull(RANGE_REGEX.find(raw)) { "未找到$RANGE_REGEX" } 27 | .destructured.let { (start, end) -> start.toLong()..end.toLong() } 28 | } 29 | IntRange::class with RawValueArgumentParser(IntRange::class) { raw -> 30 | requireNotNull(RANGE_REGEX.find(raw)) { "未找到$RANGE_REGEX" } 31 | .destructured.let { (start, end) -> start.toInt()..end.toInt() } 32 | } 33 | Cron::class with { text -> 34 | try { 35 | DefaultCronParser.parse(text) 36 | } catch (cause: Exception) { 37 | throw CommandArgumentParserException( 38 | message = cause.message ?: "Cron 表达式读取错误,建议找在线表达式生成器生成", 39 | cause = cause 40 | ) 41 | } 42 | } 43 | Duration::class with { text -> 44 | try { 45 | Duration.parse(text) 46 | } catch (cause: Exception) { 47 | throw CommandArgumentParserException( 48 | message = cause.message ?: "Duration 表达式格式为 PnDTnHnMn.nS", 49 | cause = cause 50 | ) 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivDeleteCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.utils.* 6 | import xyz.cssxsh.mirai.pixiv.* 7 | import xyz.cssxsh.mirai.pixiv.model.* 8 | import xyz.cssxsh.pixiv.* 9 | import java.time.* 10 | 11 | public object PixivDeleteCommand : CompositeCommand( 12 | owner = PixivHelperPlugin, 13 | "delete", 14 | description = "PIXIV删除指令" 15 | ), PixivHelperCommand { 16 | 17 | private fun delete(artwork: ArtWorkInfo): Boolean { 18 | if (artwork.type == WorkContentType.UGOIRA.ordinal) { 19 | UgoiraImagesFolder.resolve("${artwork.pid}.gif").delete() 20 | } 21 | return images(artwork.pid).deleteRecursively() 22 | } 23 | 24 | @SubCommand 25 | @Description("删除指定作品") 26 | public suspend fun CommandSender.artwork(pid: Long, record: Boolean = false) { 27 | logger.info { "作品(${pid})信息将从缓存移除" } 28 | if (record) ArtWorkInfo.delete(pid = pid, comment = "command delete artwork in ${OffsetDateTime.now()}") 29 | val artwork = ArtWorkInfo[pid] 30 | if (artwork == null) { 31 | sendMessage("作品(${pid})不在数据库缓存中") 32 | return 33 | } 34 | sendMessage("作品(${pid})[${artwork.author.uid}]图片将删除,结果${delete(artwork)}") 35 | } 36 | 37 | @SubCommand 38 | @Description("删除指定用户作品") 39 | public suspend fun CommandSender.user(uid: Long, record: Boolean = false) { 40 | val artworks = ArtWorkInfo.user(uid) 41 | if (record) ArtWorkInfo.deleteUser(uid = uid, comment = "command delete artwork in ${OffsetDateTime.now()}") 42 | sendMessage("USER(${uid})共${artworks.size}个作品需要删除") 43 | for (artwork in artworks) { 44 | logger.info { "作品(${artwork.pid})[${artwork.author.uid}]信息将从缓存移除" } 45 | delete(artwork) 46 | } 47 | sendMessage("删除完毕") 48 | } 49 | 50 | @SubCommand 51 | @Description("删除小于指定收藏数作品") 52 | public suspend fun CommandSender.bookmarks(min: Long, record: Boolean = false) { 53 | for (range in ALL_RANGE) { 54 | if (isActive.not()) break 55 | val artworks = ArtWorkInfo.interval(range, min, 0) 56 | if (artworks.isEmpty()) continue 57 | logger.info { "{$min}(${range})共${artworks.size}个作品需要删除" } 58 | val comment = "command delete bookmarks $min in ${OffsetDateTime.now()}" 59 | for (artwork in artworks) { 60 | logger.verbose { "作品(${artwork.pid})[${artwork.author.uid}]信息将从缓存移除" } 61 | if (record) ArtWorkInfo.delete(pid = artwork.pid, comment = comment) 62 | delete(artwork) 63 | } 64 | sendMessage("删除完毕") 65 | } 66 | } 67 | 68 | @SubCommand 69 | @Description("删除大于指定页数作品(用于处理漫画作品)") 70 | public suspend fun CommandSender.page(max: Int, record: Boolean = false) { 71 | for (range in ALL_RANGE) { 72 | if (isActive.not()) break 73 | val artworks = ArtWorkInfo.interval(range, Long.MAX_VALUE, max) 74 | if (artworks.isEmpty()) continue 75 | logger.info { "[$max](${range})共${artworks.size}个作品需要删除" } 76 | val comment = "command delete page_count $max in ${OffsetDateTime.now()}" 77 | for (artwork in artworks) { 78 | logger.verbose { "作品(${artwork.pid})[${artwork.author.uid}]信息将从缓存移除" } 79 | if (record) ArtWorkInfo.delete(pid = artwork.pid, comment = comment) 80 | delete(artwork) 81 | } 82 | } 83 | sendMessage("删除完毕") 84 | } 85 | 86 | @SubCommand 87 | @Description("删除 漫画") 88 | public suspend fun CommandSender.manga(record: Boolean = false) { 89 | for (range in ALL_RANGE) { 90 | if (isActive.not()) break 91 | val artworks = ArtWorkInfo.type(range, WorkContentType.MANGA) 92 | if (artworks.isEmpty()) continue 93 | logger.info { "[manga](${range})共${artworks.size}个作品需要删除" } 94 | val comment = "command delete manga in ${OffsetDateTime.now()}" 95 | for (artwork in artworks) { 96 | logger.verbose { "作品(${artwork.pid})[${artwork.author.uid}]信息将从缓存移除" } 97 | if (record) ArtWorkInfo.delete(pid = artwork.pid, comment = comment) 98 | delete(artwork) 99 | } 100 | } 101 | sendMessage("删除完毕") 102 | } 103 | 104 | @SubCommand 105 | @Description("删除 已被记录删除作品") 106 | public suspend fun CommandSender.record() { 107 | for (range in ALL_RANGE) { 108 | if (isActive.not()) break 109 | val artworks = ArtWorkInfo.deleted(range) 110 | if (artworks.isEmpty()) continue 111 | logger.info { "[record](${range})共${artworks.size}个作品需要删除" } 112 | for (artwork in artworks) { 113 | logger.verbose { "作品(${artwork.pid})[${artwork.author.uid}]信息将从缓存移除" } 114 | delete(artwork) 115 | } 116 | } 117 | sendMessage("删除完毕") 118 | } 119 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivEroCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.console.command.descriptor.* 6 | import net.mamoe.mirai.console.util.* 7 | import net.mamoe.mirai.contact.* 8 | import net.mamoe.mirai.event.events.* 9 | import net.mamoe.mirai.message.data.* 10 | import xyz.cssxsh.mirai.pixiv.* 11 | import xyz.cssxsh.mirai.pixiv.model.* 12 | 13 | public object PixivEroCommand : SimpleCommand( 14 | owner = PixivHelperPlugin, 15 | "ero", "色图", "涩图", "瑟图", "[色]", 16 | description = "PIXIV色图指令" 17 | ), PixivHelperCommand { 18 | 19 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class) 20 | override val prefixOptional: Boolean = true 21 | 22 | private data class History( 23 | var last: Long = System.currentTimeMillis(), 24 | var sanity: Int = 0, 25 | var bookmarks: Long = 0 26 | ) 27 | 28 | private val History.expire get() = (System.currentTimeMillis() - last) > EroUpExpire 29 | 30 | private val histories: MutableMap<Long, History> = java.util.concurrent.ConcurrentHashMap() 31 | 32 | private fun record(pid: Long, event: MessageEvent) { 33 | StatisticEroInfo( 34 | sender = event.sender.id, 35 | group = (event.subject as? Group)?.id, 36 | pid = pid, 37 | timestamp = event.time.toLong() 38 | ).persist() 39 | } 40 | 41 | @Handler 42 | public suspend fun CommandSenderOnMessage<*>.handle(): Unit = withHelper { 43 | if (shake()) return@withHelper null 44 | val history = histories.getOrPut(fromEvent.subject.id) { History() } 45 | if ("更色" in fromEvent.message.content) { 46 | history.sanity++ 47 | } else { 48 | history.sanity = 0 49 | } 50 | if ("更好" !in fromEvent.message.content && history.expire) { 51 | history.bookmarks = 0 52 | } 53 | 54 | val artwork = ero(sanity = history.sanity, bookmarks = history.bookmarks) 55 | ?: return@withHelper "sanity >= ${history.sanity}, bookmarks >= ${history.bookmarks}, 随机失败,请刷慢一点哦" 56 | 57 | launch(SupervisorJob()) { 58 | history.last = System.currentTimeMillis() 59 | record(pid = artwork.pid, event = fromEvent) 60 | } 61 | 62 | artwork 63 | } 64 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivGetCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.console.command.descriptor.* 5 | import net.mamoe.mirai.console.util.* 6 | import xyz.cssxsh.mirai.pixiv.* 7 | 8 | public object PixivGetCommand : SimpleCommand( 9 | owner = PixivHelperPlugin, 10 | "get", "搞快点", "GKD", "[勾引]", "pid", 11 | description = "PIXIV获取指令" 12 | ), PixivHelperCommand { 13 | 14 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class) 15 | override val prefixOptional: Boolean = true 16 | 17 | @Handler 18 | public suspend fun UserCommandSender.get(pid: Long, flush: Boolean = false): Unit = withHelper { 19 | if (shake()) return@withHelper null 20 | loadIllustInfo(pid = pid, flush = flush, client = client) 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivHelperCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | 5 | public sealed interface PixivHelperCommand : Command { 6 | 7 | public companion object : Collection<PixivHelperCommand> { 8 | private val commands by lazy { 9 | PixivHelperCommand::class.sealedSubclasses.mapNotNull { kClass -> kClass.objectInstance } 10 | } 11 | 12 | override val size: Int get() = commands.size 13 | 14 | override fun contains(element: PixivHelperCommand): Boolean = commands.contains(element) 15 | 16 | override fun containsAll(elements: Collection<PixivHelperCommand>): Boolean = commands.containsAll(elements) 17 | 18 | override fun isEmpty(): Boolean = commands.isEmpty() 19 | 20 | override fun iterator(): Iterator<PixivHelperCommand> = commands.iterator() 21 | 22 | public operator fun get(name: String): PixivHelperCommand = commands.first { it.primaryName.equals(name, true) } 23 | } 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivInfoCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.contact.* 5 | import net.mamoe.mirai.message.data.* 6 | import xyz.cssxsh.mirai.pixiv.* 7 | import xyz.cssxsh.mirai.pixiv.model.* 8 | import xyz.cssxsh.pixiv.* 9 | 10 | public object PixivInfoCommand : CompositeCommand( 11 | owner = PixivHelperPlugin, 12 | "info", 13 | description = "PIXIV信息指令" 14 | ), PixivHelperCommand { 15 | 16 | @SubCommand 17 | @Description("获取用户信息") 18 | public suspend fun CommandSender.user(target: User? = user) { 19 | val message = if (target == null) { 20 | "未指定用户".toPlainText() 21 | } else { 22 | buildMessageChain { 23 | appendLine("用户: ${target.nameCardOrNick}") 24 | appendLine("使用色图指令次数: ${StatisticEroInfo.user(target.id).size}") 25 | with(StatisticTagInfo.user(target.id)) { 26 | appendLine("使用标签指令次数: $size") 27 | val total = groupBy { it.tag }.entries.sortedByDescending { it.value.size }.take(3) 28 | appendLine("检索前三的是") 29 | for ((tag, list) in total) { 30 | appendLine("$tag ${list.size} 次") 31 | } 32 | } 33 | } 34 | } 35 | sendMessage(message = message) 36 | } 37 | 38 | @SubCommand 39 | @Description("获取群组信息") 40 | public suspend fun CommandSender.group(target: Group? = subject as? Group) { 41 | val message = if (target == null) { 42 | "未指定群".toPlainText() 43 | } else { 44 | buildMessageChain { 45 | appendLine("群组: ${target.name}") 46 | with(StatisticEroInfo.group(target.id)) { 47 | appendLine("使用色图指令次数: $size") 48 | val senders = groupBy { it.sender }.entries.sortedByDescending { it.value.size }.take(3) 49 | appendLine("使用前三的是") 50 | for ((id, list) in senders) { 51 | add(At(id)) 52 | appendLine(" ${list.size} 次") 53 | } 54 | } 55 | with(StatisticTagInfo.group(target.id)) { 56 | appendLine("使用标签指令次数: $size") 57 | val senders = groupBy { it.sender }.entries.sortedByDescending { it.value.size }.take(3) 58 | appendLine("使用前三的用户是") 59 | for ((id, list) in senders) { 60 | add(At(id)) 61 | appendLine(" ${list.size} 次") 62 | } 63 | val tags = groupBy { it.tag }.entries.sortedByDescending { it.value.size }.take(3) 64 | appendLine("检索前三的标签是") 65 | for ((tag, list) in tags) { 66 | appendLine("$tag ${list.size} 次") 67 | } 68 | } 69 | } 70 | } 71 | sendMessage(message = message) 72 | } 73 | 74 | @SubCommand 75 | @Description("获取TAG指令统计信息") 76 | public suspend fun CommandSender.top(limit: Int = TAG_TOP_LIMIT) { 77 | val message = buildMessageChain { 78 | appendLine("# TAG指令关键词排行") 79 | appendLine("| index | name | count |") 80 | appendLine("| --- | --- | --- |") 81 | StatisticTagInfo.top(limit).forEachIndexed { index, (name, count) -> 82 | appendLine("| ${index + 1} | $name | $count |") 83 | } 84 | } 85 | sendMessage(message = message) 86 | } 87 | 88 | @SubCommand 89 | @Description("获取缓存信息") 90 | public suspend fun CommandSender.cache() { 91 | val message = buildMessageChain { 92 | appendLine("记录数: ${ArtWorkInfo.count()}") 93 | appendLine("> ---------") 94 | appendLine("全年龄色图数: ${ArtWorkInfo.eros(AgeLimit.ALL)}") 95 | appendLine("R18色图数: ${ArtWorkInfo.eros(AgeLimit.R18)}") 96 | appendLine("R18G色图数: ${ArtWorkInfo.eros(AgeLimit.R18G)}") 97 | appendLine("> ---------") 98 | appendLine("插画色图数: ${ArtWorkInfo.eros(WorkContentType.ILLUST)}") 99 | appendLine("动画色图数: ${ArtWorkInfo.eros(WorkContentType.UGOIRA)}") 100 | appendLine("漫画色图数: ${ArtWorkInfo.eros(WorkContentType.MANGA)}") 101 | appendLine("> ---------") 102 | appendLine("Sanity(0)色图数: ${ArtWorkInfo.eros(SanityLevel.UNCHECKED)}") 103 | appendLine("Sanity(1)色图数: ${ArtWorkInfo.eros(SanityLevel.TEMP1)}") 104 | appendLine("Sanity(2)色图数: ${ArtWorkInfo.eros(SanityLevel.WHITE)}") 105 | appendLine("Sanity(3)色图数: ${ArtWorkInfo.eros(SanityLevel.TEMP3)}") 106 | appendLine("Sanity(4)色图数: ${ArtWorkInfo.eros(SanityLevel.SEMI_BLACK)}") 107 | appendLine("Sanity(5)色图数: ${ArtWorkInfo.eros(SanityLevel.TEMP5)}") 108 | appendLine("Sanity(6)色图数: ${ArtWorkInfo.eros(SanityLevel.BLACK)}") 109 | appendLine("Sanity(7)色图数: ${ArtWorkInfo.eros(SanityLevel.NONE)}") 110 | } 111 | sendMessage(message = message) 112 | } 113 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivMethodCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.client.statement.* 5 | import kotlinx.serialization.* 6 | import net.mamoe.mirai.console.command.* 7 | import net.mamoe.mirai.console.util.ContactUtils.render 8 | import net.mamoe.mirai.contact.* 9 | import net.mamoe.mirai.message.data.* 10 | import net.mamoe.mirai.utils.* 11 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 12 | import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage 13 | import xyz.cssxsh.mirai.pixiv.* 14 | import xyz.cssxsh.mirai.pixiv.data.* 15 | import xyz.cssxsh.pixiv.* 16 | import xyz.cssxsh.selenium.* 17 | import java.io.* 18 | 19 | public object PixivMethodCommand : CompositeCommand( 20 | owner = PixivHelperPlugin, 21 | "pixiv", 22 | description = "PIXIV基本方法" 23 | ), PixivHelperCommand { 24 | 25 | @SubCommand 26 | @Description("登录 通过 登录关联的微博") 27 | public suspend fun CommandSender.sina(): Unit = PixivClientPool.auth { pixiv -> 28 | when (this) { 29 | is UserCommandSender -> { 30 | val auth = pixiv.sina { url -> 31 | val qrcode = try { 32 | pixiv.useHttpClient { it.get(url).readBytes() } 33 | .toExternalResource() 34 | .use { it.uploadAsImage(subject) } 35 | } catch (cause: Exception) { 36 | logger.warning({ "微博二维码下载失败" }, cause) 37 | url.toString().toPlainText() 38 | } 39 | sendMessage(message = qrcode + " 请扫码登录关联了Pixiv的微博".toPlainText()) 40 | } 41 | 42 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功") 43 | 44 | logger.info { "账户 ${auth.user.name}#${auth.user.uid} 登陆成功,请妥善保管 RefreshToken: ${auth.refreshToken}" } 45 | } 46 | is ConsoleCommandSender -> { 47 | val auth = pixiv.sina { url -> 48 | sendMessage(message = "$url 请扫码登录关联了Pixiv的微博") 49 | } 50 | 51 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功,请妥善保管 RefreshToken: ${auth.refreshToken}") 52 | } 53 | } 54 | } 55 | 56 | @SubCommand 57 | @Description("登录 通过 Cookie") 58 | public suspend fun CommandSender.cookie(): Unit = PixivClientPool.auth { pixiv -> 59 | val json = File("cookie.json") 60 | sendMessage("加载 cookie 从 ${json.absolutePath}") 61 | val auth = pixiv.cookie { 62 | PixivJson.decodeFromString<List<EditThisCookie>>(json.readText()).map { it.toCookie() } 63 | } 64 | 65 | if (subject is Group) { 66 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功") 67 | logger.info { "账户 ${auth.user.name}#${auth.user.uid} 登陆成功,请妥善保管 RefreshToken: ${auth.refreshToken}" } 68 | } else { 69 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功,请妥善保管 RefreshToken: ${auth.refreshToken}") 70 | } 71 | } 72 | 73 | @SubCommand 74 | @Description("登录 通过 浏览器登录") 75 | public suspend fun CommandSender.selenium(): Unit = PixivClientPool.auth { pixiv -> 76 | val config = if (ProxyApi.isNotBlank()) { 77 | sendMessage("发现 pixiv-helper 配置的代理 ,将会配置给浏览器") 78 | object : RemoteWebDriverConfig { 79 | override val headless: Boolean = false 80 | override val proxy: String = ProxyApi 81 | } 82 | } else { 83 | sendMessage("浏览器将使用默认的代理配置,或者你可以在浏览器启动后更改配置") 84 | object : RemoteWebDriverConfig { 85 | override val headless: Boolean = false 86 | } 87 | } 88 | 89 | val auth = useRemoteWebDriver(config) { driver -> 90 | pixiv.selenium(driver = driver, timeout = 900_000) 91 | } 92 | 93 | if (subject is Group) { 94 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功") 95 | logger.info { "账户 ${auth.user.name}#${auth.user.uid} 登陆成功,请妥善保管 RefreshToken: ${auth.refreshToken}" } 96 | } else { 97 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功,请妥善保管 RefreshToken: ${auth.refreshToken}") 98 | } 99 | } 100 | 101 | @SubCommand 102 | @Description("登录 通过 RefreshToken") 103 | public suspend fun CommandSender.refresh(token: String): Unit = PixivClientPool.auth { pixiv -> 104 | pixiv.config { refreshToken = token } 105 | val auth = pixiv.refresh() 106 | 107 | sendMessage("账户 ${auth.user.name}#${auth.user.uid} 登陆成功") 108 | } 109 | 110 | @SubCommand 111 | @Description("绑定 Pixiv 账户 作为上下文") 112 | public suspend fun CommandSender.bind(uid: Long, contact: Contact? = subject) { 113 | PixivClientPool.bind(uid = uid, subject = contact?.id) 114 | sendMessage("对 ${contact?.render() ?: "Console"} 绑定已添加") 115 | } 116 | 117 | @SubCommand 118 | @Description("账户池详情") 119 | public suspend fun CommandSender.pool() { 120 | val message = buildMessageChain { 121 | appendLine("clients") 122 | for ((_, client) in PixivClientPool.clients) { 123 | val auth = client.auth ?: continue 124 | appendLine("User: ${auth.user.uid}") 125 | appendLine("Name: ${auth.user.name}") 126 | appendLine("Account: ${auth.user.account}") 127 | appendLine("Premium: ${auth.user.isPremium}") 128 | appendLine("AccessToken: ${auth.accessToken}") 129 | appendLine("RefreshToken: ${auth.refreshToken}") 130 | } 131 | appendLine("binded") 132 | for ((user, pixiv) in PixivClientPool.binded) { 133 | appendLine("$user - $pixiv") 134 | } 135 | appendLine("console") 136 | appendLine("${PixivClientPool.default}") 137 | } 138 | 139 | sendMessage(message = message) 140 | } 141 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivSearchCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.engine.okhttp.* 6 | import io.ktor.client.request.* 7 | import net.mamoe.mirai.console.command.* 8 | import net.mamoe.mirai.console.command.descriptor.* 9 | import net.mamoe.mirai.console.util.* 10 | import net.mamoe.mirai.console.util.ContactUtils.render 11 | import net.mamoe.mirai.contact.* 12 | import net.mamoe.mirai.message.data.* 13 | import net.mamoe.mirai.message.data.Image.Key.queryUrl 14 | import net.mamoe.mirai.message.* 15 | import net.mamoe.mirai.utils.* 16 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 17 | import xyz.cssxsh.mirai.hibernate.* 18 | import xyz.cssxsh.mirai.pixiv.* 19 | import xyz.cssxsh.mirai.pixiv.data.* 20 | import xyz.cssxsh.mirai.pixiv.model.* 21 | import xyz.cssxsh.mirai.pixiv.tools.* 22 | import java.io.* 23 | 24 | public object PixivSearchCommand : SimpleCommand( 25 | owner = PixivHelperPlugin, 26 | "search", "搜索", "搜图", 27 | description = "PIXIV搜索指令,通过 https://saucenao.com/ https://ascii2d.net/" 28 | ), PixivHelperCommand { 29 | 30 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class) 31 | override val prefixOptional: Boolean = true 32 | 33 | private suspend fun CommandSenderOnMessage<*>.getAvatar(): Image? { 34 | val at = fromEvent.message.findIsInstance<At>() ?: return null 35 | val user = when (val contact = subject) { 36 | is Group -> contact[at.target] 37 | is Friend -> if (contact.id == at.target) contact else null 38 | else -> null 39 | } ?: return null 40 | val original = "https://q.qlogo.cn/g?b=qq&nk=${user.id}&s=0" 41 | val largest = "https://q.qlogo.cn/g?b=qq&nk=${user.id}&s=640" 42 | 43 | return HttpClient(OkHttp).use { http -> 44 | try { 45 | http.get(original).body<ByteArray>().toExternalResource() 46 | } catch (_: IOException) { 47 | http.get(largest).body<ByteArray>().toExternalResource() 48 | }.use { 49 | fromEvent.subject.uploadImage(it) 50 | } 51 | } 52 | } 53 | 54 | private fun CommandSenderOnMessage<*>.getQuoteImage(): Image? { 55 | val quote = fromEvent.message.findIsInstance<QuoteReply>() ?: return null 56 | return MiraiHibernateRecorder[quote.source] 57 | .firstNotNullOfOrNull { it.toMessageSource().originalMessage.findIsInstance<Image>() } 58 | } 59 | 60 | private fun CommandSenderOnMessage<*>.getCurrentImage(): Image? { 61 | val current = fromEvent.message.findIsInstance<Image>() 62 | if (current != null) return current 63 | return MiraiHibernateRecorder 64 | .get(contact = fromEvent.subject, start = fromEvent.time - ImageSearchConfig.wait, end = fromEvent.time) 65 | .firstNotNullOfOrNull { it.toMessageSource().originalMessage.findIsInstance<Image>() } 66 | } 67 | 68 | private suspend fun CommandSenderOnMessage<*>.getNextImage(): Image { 69 | sendMessage("${ImageSearchConfig.wait}s内,请发送图片") 70 | val next = fromEvent.nextMessage(ImageSearchConfig.wait * 1000L) { 71 | Image in it.message || FlashImage in it.message 72 | } 73 | return next.findIsInstance<Image>() ?: next.firstIsInstance<FlashImage>().image 74 | } 75 | 76 | private suspend fun saucenao(url: String): List<SearchResult> { 77 | return try { 78 | ImageSearcher.saucenao(url = url) 79 | } catch (cause: Exception) { 80 | logger.warning({ "saucenao 搜索 $url 失败" }, cause) 81 | emptyList() 82 | } 83 | } 84 | 85 | private suspend fun ascii2d(url: String): List<SearchResult> { 86 | return try { 87 | ImageSearcher.ascii2d(url = url, bovw = ImageSearchConfig.bovw) 88 | } catch (cause: Exception) { 89 | logger.warning({ "ascii2d 搜索 $url 失败" }, cause) 90 | emptyList() 91 | } 92 | } 93 | 94 | private fun List<SearchResult>.similarity(min: Double): List<SearchResult> { 95 | return filterIsInstance<PixivSearchResult>() 96 | .filter { it.similarity > min } 97 | .distinctBy { it.pid } 98 | .ifEmpty { filter { it.similarity > min } } 99 | .ifEmpty { this } 100 | .sortedByDescending { it.similarity } 101 | } 102 | 103 | private fun record(hash: String): PixivSearchResult? { 104 | if (hash.isNotBlank()) return null 105 | val cache = PixivSearchResult[hash] 106 | if (cache != null) return cache 107 | val file = FileInfo[hash].firstOrNull() 108 | if (file != null) { 109 | val resutlt = PixivSearchResult(md5 = hash, similarity = 1.0, pid = file.pid) 110 | resutlt.associate() 111 | return resutlt 112 | } 113 | return null 114 | } 115 | 116 | private fun List<SearchResult>.translate(hash: String) = mapIndexedNotNull { index, result -> 117 | if (index >= ImageSearchConfig.limit) return@mapIndexedNotNull null 118 | when (result) { 119 | is PixivSearchResult -> result.apply { md5 = hash } 120 | is TwitterSearchResult -> record(result.md5)?.apply { md5 = hash } ?: result 121 | is OtherSearchResult -> result 122 | } 123 | } 124 | 125 | @Handler 126 | public suspend fun CommandSenderOnMessage<*>.search(): Unit = withHelper { 127 | if (shake()) return@withHelper null 128 | val origin = getQuoteImage() ?: getCurrentImage() ?: getAvatar() ?: getNextImage() 129 | val url = origin.queryUrl() 130 | logger.info { "${fromEvent.sender.render()} 搜索 $url" } 131 | val hash = origin.md5.toUHexString("") 132 | 133 | val record = record(hash) 134 | if (record != null) return@withHelper record.toMessage() + record.toIllustMessage(contact = fromEvent.subject) 135 | 136 | val saucenao = saucenao(url).similarity(MIN_SIMILARITY).translate(hash) 137 | 138 | val records = if (saucenao.none { it.similarity > MIN_SIMILARITY }) { 139 | saucenao + ascii2d(url).translate(hash) 140 | } else { 141 | saucenao 142 | } 143 | 144 | buildSearchMessage(results = records, sender = fromEvent.sender) 145 | } 146 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivSettingCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import xyz.cssxsh.mirai.pixiv.* 5 | import xyz.cssxsh.mirai.pixiv.data.* 6 | 7 | public object PixivSettingCommand : CompositeCommand( 8 | owner = PixivHelperPlugin, 9 | "setting", 10 | description = "PIXIV设置" 11 | ), PixivHelperCommand { 12 | 13 | @SubCommand 14 | @Description("设置Task连续发送间隔时间, 单位秒") 15 | public suspend fun CommandSender.interval(sec: Int) { 16 | val old = PixivConfigData.interval 17 | PixivConfigData.interval = sec 18 | sendMessage(message = "$old -> ${sec}s") 19 | } 20 | 21 | @SubCommand 22 | @Description("设置Task通过转发发送") 23 | public suspend fun CommandSender.forward() { 24 | PixivConfigData.forward = !TaskForward 25 | sendMessage(message = "$TaskForward") 26 | } 27 | 28 | @SubCommand 29 | @Description("设置是否显示Pixiv Cat 原图链接") 30 | public suspend fun UserCommandSender.link(): Unit = withHelper { 31 | link = !link 32 | link 33 | } 34 | 35 | @SubCommand 36 | @Description("设置是否显示TAG INFO") 37 | public suspend fun UserCommandSender.tag(): Unit = withHelper { 38 | tag = !tag 39 | tag 40 | } 41 | 42 | @SubCommand 43 | @Description("设置是否显示作品属性") 44 | public suspend fun UserCommandSender.attr(): Unit = withHelper { 45 | attr = !attr 46 | attr 47 | } 48 | 49 | @SubCommand 50 | @Description("设置是否显示最大图片数") 51 | public suspend fun UserCommandSender.max(num: Int): Unit = withHelper { 52 | val old = max 53 | max = num 54 | "$old -> $num" 55 | } 56 | 57 | @SubCommand 58 | @Description("设置发送模式, type: NORMAL, FLASH, RECALL, FORWARD") 59 | public suspend fun UserCommandSender.model(type: String, ms: Long = 60_000L): Unit = withHelper { 60 | val new = SendModel(type, ms) 61 | val old = model 62 | model = new 63 | "$old -> $new" 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivTagCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.console.command.descriptor.* 6 | import net.mamoe.mirai.console.util.* 7 | import net.mamoe.mirai.contact.* 8 | import net.mamoe.mirai.event.events.* 9 | import xyz.cssxsh.mirai.pixiv.* 10 | import xyz.cssxsh.mirai.pixiv.model.* 11 | 12 | public object PixivTagCommand : SimpleCommand( 13 | owner = PixivHelperPlugin, 14 | "tag", "标签", "[饥饿]", 15 | description = "PIXIV标签" 16 | ), PixivHelperCommand { 17 | 18 | @OptIn(ConsoleExperimentalApi::class, ExperimentalCommandDescriptors::class) 19 | override val prefixOptional: Boolean = true 20 | 21 | private fun record(tag: String, pid: Long?, event: MessageEvent) { 22 | StatisticTagInfo( 23 | sender = event.sender.id, 24 | group = (event.subject as? Group)?.id, 25 | pid = pid, 26 | tag = tag, 27 | timestamp = event.time.toLong() 28 | ).persist() 29 | } 30 | 31 | @Handler 32 | public suspend fun CommandSenderOnMessage<*>.handle(word: String, bookmarks: Long = 0): Unit = withHelper { 33 | if (shake()) return@withHelper null 34 | val artwork = tag(word = word, bookmarks = bookmarks, fuzzy = false) 35 | ?: tag(word = word, bookmarks = bookmarks, fuzzy = true) 36 | 37 | launch(SupervisorJob()) { 38 | record(tag = word, pid = artwork?.pid, event = fromEvent) 39 | } 40 | 41 | artwork ?: "$subject 读取Tag[${word}]色图失败, 请尝试日文和英文" 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/command/PixivTaskCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.command 2 | 3 | import com.cronutils.model.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.contact.* 6 | import xyz.cssxsh.mirai.pixiv.* 7 | import xyz.cssxsh.mirai.pixiv.task.* 8 | import xyz.cssxsh.pixiv.* 9 | 10 | public object PixivTaskCommand : CompositeCommand( 11 | owner = PixivHelperPlugin, 12 | "task", 13 | description = "PIXIV定时器", 14 | overrideContext = PixivCommandArgumentContext 15 | ), PixivHelperCommand { 16 | 17 | public suspend fun CommandSender.task(block: () -> PixivTimerTask) { 18 | val message = try { 19 | val task = block() 20 | PixivScheduler += task 21 | "任务 ${task.id} 已设置" 22 | } catch (casue: Exception) { 23 | "任务设置出错" 24 | } 25 | sendMessage(message = message) 26 | } 27 | 28 | @SubCommand 29 | @Description("推送用户新作品") 30 | public suspend fun CommandSender.user(uid: Long, cron: Cron, target: Contact? = subject): Unit = task { 31 | val subject = target ?: throw IllegalArgumentException("没有指定推送对象") 32 | 33 | PixivTimerTask.User( 34 | uid = uid, 35 | cron = cron.asData(), 36 | user = user?.id, 37 | subject = subject.id 38 | ) 39 | } 40 | 41 | @SubCommand 42 | @Description("推送排行榜新作品") 43 | public suspend fun CommandSender.rank(mode: RankMode, cron: Cron, target: Contact? = subject): Unit = task { 44 | val subject = target ?: throw IllegalArgumentException("没有指定推送对象") 45 | 46 | PixivTimerTask.Rank( 47 | mode = mode, 48 | cron = cron.asData(), 49 | user = user?.id, 50 | subject = subject.id 51 | ) 52 | } 53 | 54 | @SubCommand 55 | @Description("推送关注用户作品") 56 | public suspend fun CommandSender.follow(cron: Cron, target: Contact? = subject): Unit = task { 57 | val subject = target ?: throw IllegalArgumentException("没有指定推送对象") 58 | 59 | PixivTimerTask.Follow( 60 | cron = cron.asData(), 61 | user = user?.id, 62 | subject = subject.id 63 | ) 64 | } 65 | 66 | @SubCommand 67 | @Description("推送推荐作品") 68 | public suspend fun CommandSender.recommended(cron: Cron, target: Contact? = subject): Unit = task { 69 | val subject = target ?: throw IllegalArgumentException("没有指定推送对象") 70 | 71 | PixivTimerTask.Recommended( 72 | cron = cron.asData(), 73 | user = user?.id, 74 | subject = subject.id 75 | ) 76 | } 77 | 78 | @SubCommand 79 | @Description("推送热门标签") 80 | public suspend fun CommandSender.trending(cron: Cron, target: Contact? = subject): Unit = task { 81 | val subject = target ?: throw IllegalArgumentException("没有指定推送对象") 82 | 83 | PixivTimerTask.Trending( 84 | cron = cron.asData(), 85 | user = user?.id, 86 | subject = subject.id 87 | ) 88 | } 89 | 90 | @SubCommand 91 | @Description("定时缓存任务") 92 | public suspend fun CommandSender.cache(uid: Long, cron: Cron, vararg args: String): Unit = task { 93 | val target = subject?.id ?: throw IllegalArgumentException("请在聊天环境运行") 94 | 95 | PixivTimerTask.Cache( 96 | uid = uid, 97 | cron = cron.asData(), 98 | arguments = args.joinToString(separator = " "), 99 | user = user?.id, 100 | subject = target 101 | ) 102 | } 103 | 104 | @SubCommand 105 | @Description("任务定时") 106 | public suspend fun CommandSender.cron(id: String, cron: Cron) { 107 | val message = try { 108 | when (val task = PixivScheduler[id]) { 109 | null -> "任务不存在" 110 | else -> { 111 | task.cron = cron.asData() 112 | "定时任务${task.id}已设置 corn 为 ${task.cron}" 113 | } 114 | } 115 | } catch (cause: Exception) { 116 | "任务${id}定时失败,${cause.message}" 117 | } 118 | 119 | sendMessage(message) 120 | } 121 | 122 | @SubCommand 123 | @Description("删除任务") 124 | public suspend fun CommandSender.delete(id: String) { 125 | val message = try { 126 | when (val task = PixivScheduler.remove(id)) { 127 | null -> "任务不存在" 128 | else -> "定时任务${task.id}已删除" 129 | } 130 | } catch (cause: Exception) { 131 | "定时任务${id}删除失败,${cause.message}" 132 | } 133 | 134 | sendMessage(message) 135 | } 136 | 137 | @SubCommand 138 | @Description("查看任务详情") 139 | public suspend fun CommandSender.detail() { 140 | sendMessage(message = PixivScheduler.detail().ifEmpty { "任务列表为空" }) 141 | } 142 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/EditThisCookie.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import io.ktor.http.* 4 | import io.ktor.util.date.* 5 | import kotlinx.serialization.* 6 | 7 | @Serializable 8 | public data class EditThisCookie( 9 | @SerialName("domain") 10 | val domain: String, 11 | @SerialName("expirationDate") 12 | val expirationDate: Double? = null, 13 | @SerialName("hostOnly") 14 | val hostOnly: Boolean, 15 | @SerialName("httpOnly") 16 | val httpOnly: Boolean, 17 | @SerialName("id") 18 | val id: Int = 0, 19 | @SerialName("name") 20 | val name: String, 21 | @SerialName("path") 22 | val path: String, 23 | @SerialName("sameSite") 24 | val sameSite: String, 25 | @SerialName("secure") 26 | val secure: Boolean, 27 | @SerialName("session") 28 | val session: Boolean, 29 | @SerialName("storeId") 30 | val storeId: String, 31 | @SerialName("value") 32 | val value: String 33 | ) 34 | 35 | public fun EditThisCookie.toCookie(): Cookie = Cookie( 36 | name = name, 37 | value = value, 38 | encoding = CookieEncoding.DQUOTES, 39 | expires = expirationDate?.run { GMTDate(times(1000).toLong()) }, 40 | domain = domain, 41 | path = path, 42 | secure = secure, 43 | httpOnly = httpOnly 44 | ) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/EroStandardConfig.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import xyz.cssxsh.pixiv.* 4 | 5 | public interface EroStandardConfig { 6 | 7 | public val types: Set<WorkContentType> 8 | 9 | public val marks: Long 10 | 11 | public val pages: Int 12 | 13 | public val tagExclude: Regex 14 | 15 | public val userExclude: Set<Long> 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/EroStandardData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import kotlinx.serialization.* 4 | import xyz.cssxsh.pixiv.* 5 | 6 | @Serializable 7 | public data class EroStandardData( 8 | @SerialName("ero_work_types") 9 | override val types: Set<WorkContentType>, 10 | @SerialName("ero_bookmarks") 11 | override val marks: Long, 12 | @SerialName("ero_page_count") 13 | override val pages: Int, 14 | @SerialName("ero_tag_exclude") 15 | @Serializable(RegexSerializer::class) 16 | override val tagExclude: Regex, 17 | @SerialName("ero_user_exclude") 18 | override val userExclude: Set<Long> 19 | ) : EroStandardConfig 20 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/ImageSearchConfig.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import net.mamoe.mirai.console.data.* 4 | 5 | public object ImageSearchConfig : ReadOnlyPluginConfig("ImageSearchConfig") { 6 | @ValueDescription("请到 https://saucenao.com/user.php 获取") 7 | public val key: String by value("") 8 | 9 | @ValueDescription("搜索显示的结果个数") 10 | public val limit: Int by value(3) 11 | 12 | @ValueDescription("ascii2d 检索类型,false 色合検索 true 特徴検索") 13 | public val bovw: Boolean by value(true) 14 | 15 | @ValueDescription("图片等待时间,单位秒") 16 | public val wait: Int by value(300) 17 | 18 | @ValueDescription("转发方式发送搜索结果") 19 | public val forward: Boolean by value(false) 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/PixivAuthData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import net.mamoe.mirai.console.data.* 4 | import xyz.cssxsh.mirai.pixiv.* 5 | import xyz.cssxsh.pixiv.auth.* 6 | import kotlin.properties.* 7 | import kotlin.reflect.* 8 | 9 | public object PixivAuthData : AutoSavePluginData("PixivAuthData"), 10 | ReadWriteProperty<PixivClientPool.AuthClient, AuthResult?> { 11 | 12 | override fun getValue(thisRef: PixivClientPool.AuthClient, property: KProperty<*>): AuthResult? { 13 | return results[thisRef.uid] 14 | } 15 | 16 | override fun setValue(thisRef: PixivClientPool.AuthClient, property: KProperty<*>, value: AuthResult?) { 17 | if (value == null) { 18 | results.remove(thisRef.uid) 19 | } else { 20 | results[thisRef.uid] = value 21 | } 22 | } 23 | 24 | @ValueName("auth_result") 25 | public val results: MutableMap<Long, AuthResult> by value() 26 | 27 | @ValueName("bind") 28 | public val binded: MutableMap<Long, Long> by value() 29 | 30 | @ValueName("default") 31 | public var default: Long by value() 32 | 33 | public operator fun plusAssign(auth: AuthResult) { 34 | results[auth.user.uid] = auth 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/PixivConfigData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import net.mamoe.mirai.console.data.* 4 | 5 | public object PixivConfigData : AutoSavePluginConfig("PixivConfig") { 6 | 7 | @ValueName("link") 8 | @ValueDescription("是否显示原图链接") 9 | public val link: MutableMap<Long, Boolean> by value() 10 | 11 | @ValueName("tag") 12 | @ValueDescription("是否显示Tag信息") 13 | public val tag: MutableMap<Long, Boolean> by value() 14 | 15 | @ValueName("attr") 16 | @ValueDescription("是否显示Attr信息") 17 | public val attr: MutableMap<Long, Boolean> by value() 18 | 19 | @ValueName("pages") 20 | @ValueDescription("发送图片页数") 21 | public val max: MutableMap<Long, Int> by value() 22 | 23 | @ValueName("model") 24 | @ValueDescription("发送模式 NORMAL, FLASH, RECALL, FORWARD") 25 | public val model: MutableMap<Long, SendModel> by value() 26 | 27 | @ValueName("interval") 28 | @ValueDescription("task连续发送间隔时间,单位秒") 29 | public var interval: Int by value(10) 30 | 31 | @ValueName("forward") 32 | @ValueDescription("task通过转发发送") 33 | public var forward: Boolean by value(true) 34 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/PixivGifConfig.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import com.squareup.gifencoder.* 4 | import net.mamoe.mirai.console.data.* 5 | 6 | public object PixivGifConfig : ReadOnlyPluginConfig("PixivGifConfig") { 7 | public val QUANTIZER_LIST: List<String> = listOf( 8 | "com.squareup.gifencoder.UniformQuantizer", 9 | "com.squareup.gifencoder.MedianCutQuantizer", 10 | "com.squareup.gifencoder.OctTreeQuantizer", 11 | "com.squareup.gifencoder.KMeansQuantizer", 12 | "xyz.cssxsh.pixiv.tool.OpenCVQuantizer" 13 | ) 14 | 15 | public val DITHERER_LIST: List<String> = listOf( 16 | "com.squareup.gifencoder.FloydSteinbergDitherer", 17 | "com.squareup.gifencoder.NearestColorDitherer", 18 | "xyz.cssxsh.pixiv.tool.AtkinsonDitherer", 19 | "xyz.cssxsh.pixiv.tool.JJNDitherer", 20 | "xyz.cssxsh.pixiv.tool.SierraLiteDitherer", 21 | "xyz.cssxsh.pixiv.tool.StuckiDitherer" 22 | ) 23 | 24 | @ValueName("quantizer") 25 | @ValueDescription("编码器") 26 | public val quantizer: String by value("com.squareup.gifencoder.OctTreeQuantizer") 27 | 28 | @ValueName("ditherer") 29 | @ValueDescription("抖动器") 30 | public val ditherer: String by value("xyz.cssxsh.pixiv.tool.AtkinsonDitherer") 31 | 32 | @ValueName("disposal") 33 | @ValueDescription("切换方法") 34 | public val disposal: DisposalMethod by value(DisposalMethod.UNSPECIFIED) 35 | 36 | @ValueName("max_count") 37 | @ValueDescription("OpenCVQuantizer 最大迭代数") 38 | public val maxCount: Int by value(32) 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/PixivHelperSettings.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import kotlinx.serialization.modules.* 4 | import net.mamoe.mirai.console.data.* 5 | import net.mamoe.mirai.console.plugin.jvm.* 6 | import net.mamoe.mirai.console.util.* 7 | import xyz.cssxsh.mirai.pixiv.* 8 | import xyz.cssxsh.pixiv.* 9 | import java.io.File 10 | 11 | public object PixivHelperSettings : ReadOnlyPluginConfig("PixivHelperSettings"), EroStandardConfig { 12 | override val serializersModule: SerializersModule = SerializersModule { 13 | contextual(RegexSerializer) 14 | contextual(WorkContentType) 15 | } 16 | 17 | @ValueName("cache_path") 18 | @ValueDescription("缓存目录") 19 | private val cachePath: String by value(System.getenv("PIXIV_CACHE").orEmpty()) 20 | 21 | @ValueName("backup_path") 22 | @ValueDescription("备份目录") 23 | private val backupPath: String by value(System.getenv("PIXIV_BACKUP").orEmpty()) 24 | 25 | @ValueName("temp_path") 26 | @ValueDescription("临时目录") 27 | private val tempPath: String by value(System.getenv("PIXIV_TEMP").orEmpty()) 28 | 29 | @ValueName("ero_chunk") 30 | @ValueDescription("涩图防重复间隔") 31 | public val eroChunk: Int by value(ERO_CHUNK) 32 | 33 | @ValueName("ero_up_expire") 34 | @ValueDescription("色图自动触发更高收藏数的最大时间,单位毫秒") 35 | public val eroUpExpire: Long by value(ERO_UP_EXPIRE) 36 | 37 | @ValueName("ero_work_types") 38 | @ValueDescription("涩图标准 内容类型 ILLUST, UGOIRA, MANGA, 为空则全部符合") 39 | override val types: Set<WorkContentType> by value(setOf(WorkContentType.ILLUST)) 40 | 41 | @ValueName("ero_bookmarks") 42 | @ValueDescription("涩图标准 收藏") 43 | override val marks: Long by value(ERO_BOOKMARKS) 44 | 45 | @ValueName("ero_page_count") 46 | @ValueDescription("涩图标准 页数") 47 | override val pages: Int by value(ERO_PAGE_COUNT) 48 | 49 | @ValueName("ero_tag_exclude") 50 | @ValueDescription("涩图标准 排除的正则表达式") 51 | override val tagExclude: Regex by value(ERO_TAG_EXCLUDE) 52 | 53 | @ValueName("ero_user_exclude") 54 | @ValueDescription("涩图标准 排除的UID") 55 | override val userExclude: Set<Long> by value(emptySet()) 56 | 57 | @ValueName("pximg") 58 | @ValueDescription("i.pximg.net 反向代理,若非特殊情况不要修改这个配置,保持留空,可以使用 $PixivMirrorHost") 59 | public val pximg: String by value("") 60 | 61 | @ValueName("proxy") 62 | @ValueDescription("API代理 格式 http://127.0.0.1:8080 or socks://127.0.0.1:1080") 63 | public val proxyApi: String by value("") 64 | 65 | @ValueName("proxy_download") 66 | @ValueDescription("DOWNLOAD代理 格式 http://127.0.0.1:8080 or socks://127.0.0.1:1080") 67 | public val proxyDownload: String by value("") 68 | 69 | // @ValueName("timeout_api") 70 | // @ValueDescription("API超时时间, 单位ms") 71 | // public val timeoutApi: Long by value(15_000L) 72 | 73 | @ValueName("timeout_download") 74 | @ValueDescription("DOWNLOAD超时时间, 单位ms") 75 | public val timeoutDownload: Long by value(30_000L) 76 | 77 | @ValueName("block_size") 78 | @ValueDescription("DOWNLOAD分块大小, 单位B, 默认 523264, 为零时, 不会分块下载") 79 | public val blockSize: Int by value(512 * HTTP_KILO) 80 | 81 | @ValueName("tag_sfw") 82 | @ValueDescription("tag 是否过滤r18 依旧不会放出图片") 83 | public val tagSFW: Boolean by value(false) 84 | 85 | @ValueName("ero_sfw") 86 | @ValueDescription("ero 是否过滤r18 依旧不会放出图片") 87 | public val eroSFW: Boolean by value(true) 88 | 89 | // @ValueName("cache_capacity") 90 | // @ValueDescription("下载缓存容量,同时下载的图片上限") 91 | // public val cacheCapacity: Int by value(3) 92 | 93 | // @ValueName("cache_jump") 94 | // @ValueDescription("缓存是否跳过下载") 95 | // public val cacheJump: Boolean by value(false) 96 | 97 | // @ValueName("upload") 98 | // @ValueDescription("压缩完成后是否上传百度云,不上传百度云则会尝试发送文件") 99 | // public val upload: Boolean by value(false) 100 | 101 | private lateinit var plugin: JvmPlugin 102 | 103 | @OptIn(ConsoleExperimentalApi::class) 104 | override fun onInit(owner: PluginDataHolder, storage: PluginDataStorage) { 105 | plugin = owner as JvmPlugin 106 | } 107 | 108 | private fun dir(path: String, default: String) = if (path.isEmpty()) plugin.resolveDataFile(default) else File(path) 109 | 110 | /** 111 | * 压缩文件保存目录 112 | */ 113 | public val backupFolder: File get() = dir(path = backupPath, default = "backup") 114 | 115 | /** 116 | * 图片缓存保存目录 117 | */ 118 | public val cacheFolder: File get() = dir(path = cachePath, default = "cache") 119 | 120 | /** 121 | * 临时文件保存目录 122 | */ 123 | public val tempFolder: File get() = dir(path = tempPath, default = "temp") 124 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/PixivTaskData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import net.mamoe.mirai.console.data.* 4 | import xyz.cssxsh.mirai.pixiv.task.* 5 | 6 | public object PixivTaskData : AutoSavePluginData("PixivTask") { 7 | @ValueName("tasks") 8 | public val tasks: MutableMap<String, PixivTimerTask> by value() 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/RegexSerializer.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | 7 | internal object RegexSerializer : KSerializer<Regex> { 8 | 9 | override val descriptor: SerialDescriptor = 10 | PrimitiveSerialDescriptor(Regex::class.qualifiedName!!, PrimitiveKind.STRING) 11 | 12 | override fun deserialize(decoder: Decoder): Regex = Regex(pattern = decoder.decodeString()) 13 | 14 | override fun serialize(encoder: Encoder, value: Regex) = encoder.encodeString(value = value.pattern) 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/data/SendModel.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | 7 | @Serializable(SendModel.Companion::class) 8 | public sealed class SendModel { 9 | override fun toString(): String = this::class.simpleName!! 10 | 11 | public object Normal : SendModel() 12 | public object Flash : SendModel() 13 | public data class Recall(val ms: Long) : SendModel() 14 | public object Forward : SendModel() 15 | 16 | @Serializable 17 | public data class Info( 18 | val type: String, 19 | val ms: Long = 60_000L 20 | ) 21 | 22 | public companion object : KSerializer<SendModel> { 23 | @OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) 24 | override val descriptor: SerialDescriptor = 25 | buildSerialDescriptor(SendModel::class.qualifiedName!!, StructureKind.OBJECT) 26 | 27 | public operator fun invoke(type: String, ms: Long = 60_000L): SendModel { 28 | return when (type.uppercase()) { 29 | "NORMAL" -> Normal 30 | "FLASH" -> Flash 31 | "RECALL" -> Recall(ms) 32 | "FORWARD" -> Forward 33 | else -> throw IllegalArgumentException("不支持的发送类型 $type") 34 | } 35 | } 36 | 37 | override fun deserialize(decoder: Decoder): SendModel { 38 | return decoder.decodeSerializableValue(Info.serializer()).let { info -> invoke(info.type, info.ms) } 39 | } 40 | 41 | override fun serialize(encoder: Encoder, value: SendModel) { 42 | encoder.encodeSerializableValue( 43 | Info.serializer(), 44 | when (value) { 45 | is Normal -> Info("NORMAL") 46 | is Flash -> Info("FLASH") 47 | is Recall -> Info("RECALL", value.ms) 48 | is Forward -> Info("FORWARD") 49 | } 50 | ) 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/event/PixivEvent.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.event 2 | 3 | import net.mamoe.mirai.event.* 4 | import xyz.cssxsh.mirai.pixiv.* 5 | 6 | public sealed interface PixivEvent : Event { 7 | public val helper: PixivHelper 8 | 9 | /** 10 | * 色图事件,用来控制色图的发送和触发缓存更新 11 | * @param helper 上下文 12 | * @param sanity San值 13 | * @param bookmarks 收藏数 14 | * @see xyz.cssxsh.mirai.pixiv.PixivHelper.ero 15 | */ 16 | public class EroPost( 17 | public override val helper: PixivHelper, 18 | public val sanity: Int = 0, 19 | public val bookmarks: Long = 0 20 | ) : PixivEvent, CancellableEvent, AbstractEvent() 21 | 22 | /** 23 | * 标签事件,用来记录和控制色图的发送 24 | * @param word 关键词 25 | * @param bookmarks 收藏数 26 | * @param fuzzy 模糊搜索 27 | * @see xyz.cssxsh.mirai.pixiv.PixivHelper.tag 28 | */ 29 | public class TagPost( 30 | public override val helper: PixivHelper, 31 | public val word: String, 32 | public val bookmarks: Long, 33 | public val fuzzy: Boolean 34 | ) : PixivEvent, AbstractEvent() 35 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/AliasSetting.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "statistic_alias") 7 | public data class AliasSetting( 8 | @Id 9 | @Column(name = "name", nullable = false) 10 | val alias: String, 11 | @Column(name = "uid", nullable = false) 12 | override val uid: Long 13 | ) : PixivEntity, Author { 14 | public companion object SQL 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/ArtWorkInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | import xyz.cssxsh.pixiv.* 5 | 6 | @Entity 7 | @Table(name = "artworks") 8 | public data class ArtWorkInfo( 9 | @Id 10 | @Column(name = "pid", nullable = false, updatable = false) 11 | val pid: Long, 12 | @Column(name = "title", nullable = false, length = 32) 13 | val title: String = "", 14 | @Column(name = "caption", nullable = false) 15 | val caption: String = "", 16 | @Column(name = "create_at", nullable = false) 17 | val created: Long = 0, 18 | @Column(name = "page_count", nullable = false) 19 | val pages: Int = 0, 20 | @Column(name = "sanity_level", nullable = false) 21 | val sanity: Int = SanityLevel.NONE.ordinal, 22 | @Column(name = "type", nullable = false) 23 | val type: Int = WorkContentType.ILLUST.ordinal, 24 | @Column(name = "width", nullable = false) 25 | val width: Int = 0, 26 | @Column(name = "height", nullable = false) 27 | val height: Int = 0, 28 | @Column(name = "total_bookmarks", nullable = false) 29 | val bookmarks: Long = 0, 30 | @Column(name = "total_comments", nullable = false) 31 | val comments: Long = 0, 32 | @Column(name = "total_view", nullable = false) 33 | val view: Long = 0, 34 | @Column(name = "age", nullable = false) 35 | val age: Int = 0, 36 | @Column(name = "is_ero", nullable = false) 37 | val ero: Boolean = false, 38 | @Column(name = "deleted", nullable = false, updatable = false) 39 | val deleted: Boolean = true, 40 | @ManyToOne(cascade = [CascadeType.MERGE], fetch = FetchType.EAGER) 41 | @JoinColumn(name = "uid", nullable = false, updatable = false) 42 | val author: UserBaseInfo = UserBaseInfo(uid = 0, name = "", account = null) 43 | ) : PixivEntity { 44 | @ManyToMany(cascade = [CascadeType.MERGE], fetch = FetchType.LAZY) 45 | @JoinTable( 46 | name = "artwork_tag", 47 | joinColumns = [JoinColumn(name = "pid", referencedColumnName = "pid")], 48 | inverseJoinColumns = [JoinColumn(name = "tid", referencedColumnName = "tid")] 49 | ) 50 | val tags: MutableList<TagRecord> = ArrayList() 51 | 52 | public companion object SQL 53 | } 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/ArtWorkTag.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "artwork_tag") 7 | public data class ArtWorkTag( 8 | @Id 9 | @Column(name = "pid", nullable = false, updatable = false) 10 | val pid: Long, 11 | @Column(name = "tid", nullable = false, updatable = false) 12 | val tid: Long = 0 13 | ) : PixivEntity { 14 | public companion object SQL 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/Author.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | public interface Author { 4 | public val uid: Long 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/FileIndex.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Embeddable 6 | public data class FileIndex( 7 | @Column(name = "pid", nullable = false, updatable = false) 8 | val pid: Long, 9 | @Column(name = "`index`", nullable = false, updatable = false) 10 | val index: Int, 11 | ) : PixivEntity 12 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/FileInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "files") 7 | public data class FileInfo( 8 | @Transient 9 | val pid: Long, 10 | @Transient 11 | val index: Int, 12 | @Column(name = "md5", nullable = false, length = 32) 13 | val md5: String, 14 | @Column(name = "url", nullable = false) 15 | val url: String, 16 | @Column(name = "size", nullable = false) 17 | val size: Int 18 | ) : PixivEntity { 19 | @EmbeddedId 20 | val id: FileIndex = FileIndex(pid = pid, index = index) 21 | 22 | public companion object SQL 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/ImageSearch.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | import kotlinx.serialization.* 5 | import kotlinx.serialization.json.* 6 | 7 | public sealed interface SearchResult { 8 | public val md5: String 9 | public val similarity: Double 10 | } 11 | 12 | @Serializable 13 | @Entity 14 | @Table(name = "statistic_search") 15 | public data class PixivSearchResult( 16 | @Id 17 | @Column(name = "md5", nullable = false, updatable = false) 18 | @SerialName("md5") 19 | override var md5: String = "", 20 | @Column(name = "similarity", nullable = false) 21 | @SerialName("similarity") 22 | override var similarity: Double = 0.0, 23 | @Column(name = "pid", nullable = false, updatable = false) 24 | @SerialName("pixiv_id") 25 | override var pid: Long = 0, 26 | @Column(name = "title", nullable = false) 27 | @SerialName("title") 28 | override var title: String = "", 29 | @Column(name = "uid", nullable = false) 30 | @SerialName("member_id") 31 | override var uid: Long = 0, 32 | @Column(name = "name", nullable = false) 33 | @SerialName("member_name") 34 | override var name: String = "" 35 | ) : SimpleArtworkInfo, SearchResult, PixivEntity { 36 | @ManyToOne(cascade = [], fetch = FetchType.EAGER) 37 | @JoinColumn(name = "pid", insertable = false, updatable = false, nullable = true) 38 | @org.hibernate.annotations.NotFound(action = org.hibernate.annotations.NotFoundAction.IGNORE) 39 | @kotlinx.serialization.Transient 40 | val artwork: ArtWorkInfo? = null 41 | 42 | public companion object SQL 43 | } 44 | 45 | public data class TwitterSearchResult( 46 | override val md5: String = "", 47 | override val similarity: Double = 0.0, 48 | val tweet: String = "", 49 | val image: String = "", 50 | ) : SearchResult 51 | 52 | public data class OtherSearchResult( 53 | override val md5: String = "", 54 | override val similarity: Double = 0.0, 55 | val text: String = "", 56 | ) : SearchResult 57 | 58 | @Serializable 59 | public data class JsonSearchResults( 60 | @SerialName("header") 61 | val info: Info, 62 | @SerialName("results") 63 | val results: List<JsonSearchResult>? = null 64 | ) { 65 | @Serializable 66 | public data class Info( 67 | @SerialName("account_type") 68 | val accountType: Int, 69 | @SerialName("index") 70 | val index: Map<Int, MapValue> = emptyMap(), 71 | @SerialName("long_limit") 72 | val longLimit: Int, 73 | @SerialName("long_remaining") 74 | val longRemaining: Int, 75 | @SerialName("minimum_similarity") 76 | val minimumSimilarity: Double = 0.0, 77 | @SerialName("query_image") 78 | val queryImage: String? = null, 79 | @SerialName("query_image_display") 80 | val queryImageDisplay: String? = null, 81 | @SerialName("results_requested") 82 | val resultsRequested: Int, 83 | @SerialName("results_returned") 84 | val resultsReturned: Int? = null, 85 | @SerialName("search_depth") 86 | val searchDepth: Int = 0, 87 | @SerialName("short_limit") 88 | val shortLimit: Int, 89 | @SerialName("short_remaining") 90 | val shortRemaining: Int, 91 | @SerialName("status") 92 | val status: Int, 93 | @SerialName("user_id") 94 | val userId: String 95 | ) { 96 | @Serializable 97 | public data class MapValue( 98 | @SerialName("id") 99 | val id: Int, 100 | @SerialName("parent_id") 101 | val parentId: Int, 102 | @SerialName("results") 103 | val results: Int = 0, 104 | @SerialName("status") 105 | val status: Int 106 | ) 107 | } 108 | } 109 | 110 | @Serializable 111 | public data class JsonSearchResult( 112 | @SerialName("data") 113 | val `data`: JsonObject, 114 | @SerialName("header") 115 | val info: Info 116 | ) { 117 | @Serializable 118 | public data class Info( 119 | @SerialName("dupes") 120 | val dupes: Int, 121 | @SerialName("index_id") 122 | val indexId: Int, 123 | @SerialName("index_name") 124 | val indexName: String, 125 | @SerialName("similarity") 126 | val similarity: Double, 127 | @SerialName("thumbnail") 128 | val thumbnail: String 129 | ) 130 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/NaviRank.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import xyz.cssxsh.pixiv.* 4 | import java.time.* 5 | 6 | public data class NaviRankAllTime( 7 | val title: String, 8 | val records: List<NaviRankRecord> 9 | ) 10 | 11 | public data class NaviRankOverTime( 12 | val title: String, 13 | val records: Map<String, List<NaviRankRecord>> 14 | ) 15 | 16 | public data class NaviRankRecord( 17 | override val pid: Long, 18 | override val title: String, 19 | val type: WorkContentType, 20 | val page: Int, 21 | val date: LocalDate, 22 | override val uid: Long, 23 | override val name: String, 24 | val tags: List<String> 25 | ) : SimpleArtworkInfo -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/PixivArticle.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import kotlinx.serialization.* 4 | 5 | @Serializable 6 | public data class PixivArticle( 7 | @SerialName("title") 8 | val title: String, 9 | @SerialName("description") 10 | val description: String, 11 | @SerialName("illusts") 12 | val illusts: List<Illust>, 13 | ) { 14 | @Serializable 15 | public data class Illust( 16 | @SerialName("pid") 17 | override val pid: Long, 18 | @SerialName("title") 19 | override val title: String, 20 | @SerialName("uid") 21 | override val uid: Long, 22 | @SerialName("name") 23 | override val name: String, 24 | ) : SimpleArtworkInfo 25 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/PixivEntity.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import java.io.* 4 | 5 | public sealed interface PixivEntity : Serializable -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/PixivHibernateConfiguration.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import net.mamoe.mirai.utils.* 4 | import org.hibernate.boot.registry.* 5 | import org.hibernate.cfg.* 6 | import xyz.cssxsh.hibernate.* 7 | import xyz.cssxsh.mirai.pixiv.* 8 | import java.io.* 9 | import java.sql.* 10 | 11 | public object PixivHibernateConfiguration : 12 | Configuration( 13 | BootstrapServiceRegistryBuilder() 14 | .applyClassLoader(PixivHelperPlugin::class.java.classLoader) 15 | .build() 16 | ) { 17 | 18 | init { 19 | setProperty("hibernate.connection.provider_class", "org.hibernate.hikaricp.internal.HikariCPConnectionProvider") 20 | setProperty("hibernate.connection.isolation", "${Connection.TRANSACTION_READ_UNCOMMITTED}") 21 | load() 22 | } 23 | 24 | private val configuration: File 25 | get() { 26 | return try { 27 | PixivHelperPlugin.configFolder.resolve("hibernate.properties") 28 | } catch (_: UninitializedPropertyAccessException) { 29 | File("hibernate.properties") 30 | } 31 | } 32 | 33 | private val default: String 34 | get() = """ 35 | hibernate.connection.url=jdbc:sqlite:file:./data/xyz.cssxsh.mirai.plugin.pixiv-helper/pixiv.sqlite 36 | hibernate.connection.driver_class=org.sqlite.JDBC 37 | hibernate.dialect=org.hibernate.community.dialect.SQLiteDialect 38 | hibernate.connection.provider_class=org.hibernate.hikaricp.internal.HikariCPConnectionProvider 39 | hibernate.connection.isolation=${Connection.TRANSACTION_READ_UNCOMMITTED} 40 | hibernate-connection-autocommit=${true} 41 | hibernate.connection.show_sql=${false} 42 | hibernate.autoReconnect=${true} 43 | hibernate.current_session_context_class=thread 44 | """.trimIndent() 45 | 46 | private fun load() { 47 | PixivEntity::class.sealedSubclasses.forEach { addAnnotatedClass(it.java) } 48 | configuration.apply { if (exists().not()) writeText(default) }.reader().use(properties::load) 49 | val url = requireNotNull(getProperty("hibernate.connection.url")) { "hibernate.connection.url cannot is null" } 50 | if (getProperty("hibernate.connection.provider_class") == "org.hibernate.connection.C3P0ConnectionProvider") { 51 | setProperty( 52 | "hibernate.connection.provider_class", 53 | "org.hibernate.hikaricp.internal.HikariCPConnectionProvider" 54 | ) 55 | logger.warning { "已经自动将 C3P0ConnectionProvider 替换为 HikariCPConnectionProvider" } 56 | } 57 | if (getProperty("hibernate.dialect") == "org.sqlite.hibernate.dialect.SQLiteDialect") { 58 | setProperty( 59 | "hibernate.dialect", 60 | "org.hibernate.community.dialect.SQLiteDialect" 61 | ) 62 | logger.warning { "已经自动将 org.sqlite.hibernate.dialect.SQLiteDialect 替换为 org.hibernate.community.dialect.SQLiteDialect" } 63 | } 64 | // 设置 rand 别名 65 | addRandFunction() 66 | // 设置 dice 宏 67 | addDiceFunction() 68 | setProperty("hibernate.hbm2ddl.auto", "none") 69 | when { 70 | url.startsWith("jdbc:sqlite") -> { 71 | // SQLite 是单文件数据库,最好只有一个连接 72 | setProperty("hibernate.c3p0.min_size", "1") 73 | setProperty("hibernate.c3p0.max_size", "1") 74 | setProperty("hibernate.hikari.minimumIdle", "1") 75 | setProperty("hibernate.hikari.maximumPoolSize", "1") 76 | } 77 | url.startsWith("jdbc:mysql") -> { 78 | // 79 | } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/SimpleArtworkInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | 4 | public interface SimpleArtworkInfo { 5 | public val pid: Long 6 | public val title: String 7 | public val uid: Long 8 | public val name: String 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/StatisticEroInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "statistic_ero") 7 | public data class StatisticEroInfo( 8 | @Id 9 | @Column(name = "sender", nullable = false) 10 | val sender: Long, 11 | @Column(name = "`group`", nullable = true) 12 | val group: Long?, 13 | @Column(name = "pid", nullable = false) 14 | val pid: Long, 15 | @Id 16 | @Column(name = "timestamp", nullable = false) 17 | val timestamp: Long 18 | ) : PixivEntity { 19 | public companion object SQL 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/StatisticTagInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "statistic_tag") 7 | public data class StatisticTagInfo( 8 | @Id 9 | @Column(name = "sender", nullable = false) 10 | val sender: Long, 11 | @Column(name = "`group`", nullable = true) 12 | val group: Long?, 13 | @Column(name = "pid", nullable = true) 14 | val pid: Long?, 15 | @Column(name = "tag", nullable = false) 16 | val tag: String, 17 | @Id 18 | @Column(name = "timestamp", nullable = false) 19 | val timestamp: Long 20 | ) : PixivEntity { 21 | public companion object SQL 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/StatisticTaskInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "statistic_task") 7 | public data class StatisticTaskInfo( 8 | @Id 9 | @Column(name = "task", nullable = false) 10 | val task: String, 11 | @Id 12 | @Column(name = "pid", nullable = false) 13 | val pid: Long, 14 | @Column(name = "timestamp", nullable = false) 15 | val timestamp: Long 16 | ) : PixivEntity { 17 | public companion object SQL 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/StatisticUserInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "statistic_user") 7 | public data class StatisticUserInfo( 8 | @Id 9 | @Column(name = "uid") 10 | val uid: Long, 11 | @Column(name = "count") 12 | val count: Long, 13 | @Column(name = "ero") 14 | val ero: Long 15 | ) : PixivEntity { 16 | public companion object SQL 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/TagRecord.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "tag") 7 | public data class TagRecord( 8 | @Id 9 | @Column(name = "name", nullable = false, length = 30, updatable = false) 10 | val name: String, 11 | @Column(name = "translated_name", nullable = true) 12 | val translated: String?, 13 | @GeneratedValue(strategy = GenerationType.IDENTITY) 14 | @Column(name = "tid", nullable = false, updatable = false, insertable = false) 15 | val tid: Long = 0 16 | ) : PixivEntity { 17 | public companion object SQL 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/Twitter.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "twitter") 7 | public data class Twitter( 8 | @Id 9 | @Column(name = "screen", nullable = false, length = 50) 10 | val screen: String, 11 | @Column(name = "uid", nullable = false, updatable = false) 12 | override val uid: Long, 13 | ) : PixivEntity, Author { 14 | public companion object SQL 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/model/UserBaseInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.model 2 | 3 | import jakarta.persistence.* 4 | 5 | @Entity 6 | @Table(name = "users") 7 | public data class UserBaseInfo( 8 | @Id 9 | @Column(name = "uid", nullable = false, updatable = false) 10 | override val uid: Long, 11 | @Column(name = "name", nullable = false, length = 15) 12 | val name: String, 13 | @Column(name = "account", nullable = true, length = 32) 14 | val account: String? 15 | ) : PixivEntity, Author { 16 | public companion object SQL 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/task/DataCron.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.task 2 | 3 | import com.cronutils.model.* 4 | import kotlinx.serialization.* 5 | import kotlinx.serialization.descriptors.* 6 | import kotlinx.serialization.encoding.* 7 | 8 | @Serializable(with = DataCron.Serializer::class) 9 | public class DataCron(public val delegate: Cron) : Cron by delegate { 10 | 11 | override fun toString(): String = delegate.asString() 12 | 13 | public companion object Serializer : KSerializer<DataCron> { 14 | 15 | override val descriptor: SerialDescriptor = 16 | PrimitiveSerialDescriptor(this::class.qualifiedName!!, PrimitiveKind.STRING) 17 | 18 | override fun serialize(encoder: Encoder, value: DataCron) { 19 | encoder.encodeString(value.asString()) 20 | } 21 | 22 | override fun deserialize(decoder: Decoder): DataCron { 23 | return DataCron(delegate = DefaultCronParser.parse(decoder.decodeString())) 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/task/DurationSerializer.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.task 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | import java.time.* 7 | 8 | public object DurationSerializer : KSerializer<Duration> { 9 | override val descriptor: SerialDescriptor = 10 | PrimitiveSerialDescriptor(this::class.qualifiedName!!, PrimitiveKind.STRING) 11 | 12 | override fun serialize(encoder: Encoder, value: Duration) { 13 | encoder.encodeString(value.toString()) 14 | } 15 | 16 | override fun deserialize(decoder: Decoder): Duration { 17 | return Duration.parse(decoder.decodeString()) 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/task/Parser.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.task 2 | 3 | import com.cronutils.descriptor.* 4 | import com.cronutils.model.* 5 | import com.cronutils.model.definition.* 6 | import com.cronutils.model.time.* 7 | import com.cronutils.parser.* 8 | import java.util.* 9 | 10 | internal const val CRON_TYPE_KEY = "xyz.cssxsh.mirai.cron.type" 11 | 12 | public val DefaultCronParser: CronParser by lazy { 13 | val type = CronType.valueOf(System.getProperty(CRON_TYPE_KEY, CronType.QUARTZ.name)) 14 | CronParser(CronDefinitionBuilder.instanceDefinitionFor(type)) 15 | } 16 | 17 | internal const val CRON_LOCALE_KEY = "xyz.cssxsh.mirai.cron.locale" 18 | 19 | public val DefaultCronDescriptor: CronDescriptor by lazy { 20 | val locale = System.getProperty(CRON_LOCALE_KEY)?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() 21 | CronDescriptor.instance(locale) 22 | } 23 | 24 | public fun Cron.asData(): DataCron = this as? DataCron ?: DataCron(delegate = this) 25 | 26 | public fun Cron.toExecutionTime(): ExecutionTime = ExecutionTime.forCron((this as? DataCron)?.delegate ?: this) 27 | 28 | public fun Cron.description(): String = DefaultCronDescriptor.describe(this) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/task/PixivTimerTask.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.task 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.serialization.* 5 | import xyz.cssxsh.pixiv.* 6 | import xyz.cssxsh.pixiv.apps.* 7 | 8 | @Serializable 9 | public sealed class PixivTimerTask { 10 | public abstract var cron: DataCron 11 | public abstract val user: Long? 12 | public abstract val subject: Long 13 | public abstract val id: String 14 | 15 | @Transient 16 | public val illusts: MutableList<IllustInfo> = ArrayList() 17 | 18 | @Transient 19 | public val mutex: Mutex = Mutex() 20 | 21 | @Serializable 22 | @SerialName("User") 23 | public data class User( 24 | @SerialName("uid") 25 | val uid: Long, 26 | @SerialName("cron") 27 | override var cron: DataCron, 28 | @SerialName("user") 29 | override val user: Long?, 30 | @SerialName("subject") 31 | override val subject: Long, 32 | ) : PixivTimerTask() { 33 | @Transient 34 | override val id: String = "User($uid)[$subject]" 35 | } 36 | 37 | @Serializable 38 | @SerialName("Rank") 39 | public data class Rank( 40 | @SerialName("mode") 41 | val mode: RankMode, 42 | @SerialName("cron") 43 | override var cron: DataCron, 44 | @SerialName("user") 45 | override val user: Long?, 46 | @SerialName("subject") 47 | override val subject: Long, 48 | ) : PixivTimerTask() { 49 | @Transient 50 | override val id: String = "Rank[$subject]" 51 | } 52 | 53 | @Serializable 54 | @SerialName("Follow") 55 | public data class Follow( 56 | @SerialName("cron") 57 | override var cron: DataCron, 58 | @SerialName("user") 59 | override val user: Long?, 60 | @SerialName("subject") 61 | override val subject: Long, 62 | ) : PixivTimerTask() { 63 | @Transient 64 | override val id: String = "Follow[$subject]" 65 | } 66 | 67 | @Serializable 68 | @SerialName("Recommended") 69 | public data class Recommended( 70 | @SerialName("cron") 71 | override var cron: DataCron, 72 | @SerialName("user") 73 | override val user: Long?, 74 | @SerialName("subject") 75 | override val subject: Long, 76 | ) : PixivTimerTask() { 77 | @Transient 78 | override val id: String = "Recommended[$subject]" 79 | } 80 | 81 | @Serializable 82 | @SerialName("Trending") 83 | public data class Trending( 84 | @SerialName("cron") 85 | override var cron: DataCron, 86 | @SerialName("user") 87 | override val user: Long?, 88 | @SerialName("subject") 89 | override val subject: Long 90 | ) : PixivTimerTask() { 91 | @Transient 92 | override val id: String = "Trending[$subject]" 93 | } 94 | 95 | @Serializable 96 | @SerialName("Cache") 97 | public data class Cache( 98 | @SerialName("uid") 99 | val uid: Long, 100 | @SerialName("cron") 101 | override var cron: DataCron, 102 | @SerialName("arguments") 103 | val arguments: String, 104 | @SerialName("user") 105 | override val user: Long?, 106 | @SerialName("subject") 107 | override val subject: Long 108 | ) : PixivTimerTask() { 109 | @Transient 110 | override val id: String = "Cache($arguments)[$subject]" 111 | } 112 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/tools/HtmlParser.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.tools 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.engine.okhttp.* 5 | import io.ktor.client.plugins.* 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | import kotlinx.coroutines.* 10 | import net.mamoe.mirai.utils.* 11 | import org.jsoup.* 12 | import org.jsoup.nodes.* 13 | import org.jsoup.select.* 14 | import xyz.cssxsh.mirai.pixiv.* 15 | import xyz.cssxsh.pixiv.* 16 | import xyz.cssxsh.pixiv.tool.* 17 | import java.io.IOException 18 | 19 | @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") 20 | public abstract class HtmlParser(public val name: String) { 21 | protected val logger: MiraiLogger by lazy { MiraiLogger.Factory.create(this::class, identity = name) } 22 | 23 | protected fun ignore(throwable: Throwable): Boolean { 24 | return when (throwable) { 25 | is IOException -> { 26 | logger.warning { "$name Api 错误, 已忽略: ${throwable.message}" } 27 | true 28 | } 29 | else -> false 30 | } 31 | } 32 | 33 | protected open val client: HttpClient = HttpClient(OkHttp) { 34 | BrowserUserAgent() 35 | engine { 36 | config { 37 | if (ProxyApi.isNotBlank()) { 38 | proxy(Url(ProxyApi).toProxy()) 39 | } else { 40 | sslSocketFactory(RubySSLSocketFactory, RubyX509TrustManager) 41 | hostnameVerifier { _, _ -> true } 42 | dns(RubyDns(JAPAN_DNS, PIXIV_HOST)) 43 | } 44 | } 45 | } 46 | } 47 | 48 | protected fun sni(host: Regex): Boolean = RubySSLSocketFactory.regexes.add(host) 49 | 50 | protected fun Elements.findAll(regex: Regex): Sequence<MatchResult> = regex.findAll(html()) 51 | 52 | protected fun Element.href(): String = attr("href") 53 | 54 | protected suspend fun <R> http(block: suspend (HttpClient) -> R): R = supervisorScope { 55 | var cause: Throwable? = null 56 | while (isActive) { 57 | try { 58 | return@supervisorScope block(client) 59 | } catch (throwable: Throwable) { 60 | if (ignore(throwable)) { 61 | cause = throwable 62 | } else { 63 | throw throwable 64 | } 65 | } 66 | } 67 | throw CancellationException(null, cause) 68 | } 69 | 70 | public suspend fun <T> html(transform: (Document) -> T, block: HttpRequestBuilder.() -> Unit): T = http { 71 | transform(Jsoup.parse(it.request(block).bodyAsText())) 72 | } 73 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/tools/ImageSearcher.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.tools 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.plugins.* 6 | import io.ktor.client.plugins.contentnegotiation.* 7 | import io.ktor.client.request.* 8 | import io.ktor.client.statement.* 9 | import io.ktor.http.* 10 | import io.ktor.serialization.kotlinx.json.* 11 | import kotlinx.serialization.json.* 12 | import org.jsoup.* 13 | import org.jsoup.nodes.* 14 | import xyz.cssxsh.mirai.pixiv.model.* 15 | import xyz.cssxsh.pixiv.* 16 | 17 | public object ImageSearcher : HtmlParser(name = "Search") { 18 | 19 | init { 20 | if (System.getProperty("xyz.cssxsh.mirai.plugin.tools.saucenao", "${true}").toBoolean()) { 21 | sni("""saucenao\.com""".toRegex()) 22 | } 23 | } 24 | 25 | private const val API = "https://saucenao.com/search.php" 26 | 27 | private const val ALL_INDEX = 999 28 | 29 | internal var key: String = "" 30 | 31 | override val client: HttpClient = super.client.config { 32 | install(ContentNegotiation) { 33 | json(json = PixivJson) 34 | } 35 | defaultRequest { 36 | url("https://saucenao.com") 37 | header(HttpHeaders.Accept, ContentType.Text.Html) 38 | } 39 | } 40 | 41 | private val MD5 = """[0-9a-f]{32}""".toRegex() 42 | 43 | private val BASE64 = """[\w-=]{15}\.[\w]{3}""".toRegex() 44 | 45 | private val ID = """\d{3,9}""".toRegex() 46 | 47 | private val image = { name: String -> 48 | when { 49 | MD5 in name -> { 50 | val md5 = MD5.find(name)!!.value 51 | "https://img1.gelbooru.com/images/${md5.substring(0..1)}/${md5.substring(2..3)}/${md5}.jpg" to md5 52 | } 53 | BASE64 in name -> { 54 | val base64 = BASE64.find(name)!!.value 55 | "https://pbs.twimg.com/media/${base64}?name=orig" to "" 56 | } 57 | else -> { 58 | "无" to "" 59 | } 60 | } 61 | } 62 | 63 | private fun Element.similarity() = select(".resultsimilarityinfo").text().replace("%", "").toDouble() / 100 64 | 65 | private val other: (Document) -> List<SearchResult> = { document -> 66 | document.select(".resulttable").map { content -> 67 | val result = content.select(".resultcontent") 68 | val links = result.select("a") 69 | when { 70 | "Pixiv" in content.text() && result.select("a").isNotEmpty() -> { 71 | PixivSearchResult( 72 | similarity = content.similarity(), 73 | pid = result.findAll(ID).first().value.toLong(), 74 | title = result.text().substringBeforeLast("Pixiv ID:", "").trim(), 75 | uid = links.last()!!.href().let(::Url).parameters["id"]?.toLongOrNull() ?: 0, 76 | name = result.text().substringAfterLast("Member:", "").trim() 77 | ) 78 | } 79 | "Twitter" in content.text() && links.isNotEmpty() -> { 80 | val (image, md5) = content.select(".resulttableimage").html().let(image) 81 | TwitterSearchResult( 82 | similarity = content.similarity(), 83 | tweet = links.first()!!.href(), 84 | image = image, 85 | md5 = md5 86 | ) 87 | } 88 | else -> { 89 | OtherSearchResult( 90 | similarity = content.similarity(), 91 | text = content.wholeText() 92 | ) 93 | } 94 | } 95 | } 96 | } 97 | 98 | internal suspend fun html(url: String): List<SearchResult> = html(other) { 99 | url(API) 100 | method = HttpMethod.Get 101 | parameter("url", url) 102 | parameter("dbs[]", ALL_INDEX) 103 | } 104 | 105 | private fun JsonSearchResults.decode(): List<SearchResult> { 106 | return (results ?: return emptyList()).map { 107 | val source = it.data["source"]?.jsonPrimitive?.content.orEmpty() 108 | when { 109 | "pixiv_id" in it.data -> { 110 | PixivJson.decodeFromJsonElement<PixivSearchResult>(it.data) 111 | .copy(similarity = it.info.similarity / 100) 112 | } 113 | "tweet_id" in it.data -> { 114 | val (image, md5) = image(it.info.indexName) 115 | TwitterSearchResult( 116 | similarity = it.info.similarity / 100, 117 | tweet = "https://twitter.com/detail/status/${it.data.getValue("tweet_id").jsonPrimitive.content}", 118 | image = image, 119 | md5 = md5 120 | ) 121 | } 122 | "i.pximg.net" in source || "www.pixiv.net" in source -> { 123 | PixivSearchResult( 124 | similarity = it.info.similarity / 100, 125 | pid = source.substringAfterLast("/") 126 | .substringAfterLast('=') 127 | .substringBeforeLast('_') 128 | .substringBeforeLast('#') 129 | .toLong(), 130 | title = it.info.indexName, 131 | uid = 0, 132 | name = "" 133 | ) 134 | } 135 | "pbs.twimg.com" in source || "twitter.com" in source -> { 136 | val (image, md5) = image(it.info.indexName) 137 | TwitterSearchResult( 138 | similarity = it.info.similarity / 100, 139 | tweet = source, 140 | image = image, 141 | md5 = md5 142 | ) 143 | } 144 | else -> { 145 | OtherSearchResult( 146 | similarity = it.info.similarity / 100, 147 | text = it.data.entries.joinToString("\n") { (value, element) -> "$value: $element" } 148 | ) 149 | } 150 | } 151 | } 152 | } 153 | 154 | internal suspend fun json(url: String): List<SearchResult> = http { client -> 155 | client.get(API) { 156 | parameter("url", url) 157 | parameter("output_type", 2) 158 | parameter("api_key", key) 159 | // parameter("testmode", ) 160 | // parameter("dbmask", ) 161 | // parameter("dbmaski", ) 162 | parameter("db", ALL_INDEX) 163 | // parameter("numres", ) 164 | // parameter("dedupe", ) 165 | }.body<JsonSearchResults>().decode() 166 | } 167 | 168 | public suspend fun saucenao(url: String): List<SearchResult> { 169 | return if (key.isBlank()) html(url = url) else json(url = url) 170 | } 171 | 172 | private val thumbnail = { hash: String -> 173 | "https://ascii2d.net/thumbnail/${hash[0]}/${hash[1]}/${hash[2]}/${hash[3]}/${hash}.jpg" 174 | } 175 | 176 | private val ascii2d: (Document) -> List<SearchResult> = { document -> 177 | document.select(".item-box").mapNotNull { content -> 178 | val small = content.select(".detail-box small").text() 179 | val link = content.select(".detail-box a").map { it.text() to it.href() } 180 | val hash = content.select(".hash").text() 181 | when (small) { 182 | "pixiv" -> { 183 | PixivSearchResult( 184 | similarity = Double.NaN, 185 | pid = ID.find(link[0].second)!!.value.toLong(), 186 | title = link[0].first, 187 | uid = ID.find(link[1].second)!!.value.toLong(), 188 | name = link[1].first 189 | ) 190 | } 191 | "twitter" -> { 192 | TwitterSearchResult( 193 | similarity = Double.NaN, 194 | md5 = hash, 195 | tweet = link[0].second, 196 | image = thumbnail(hash) 197 | ) 198 | } 199 | else -> null 200 | } 201 | } 202 | } 203 | 204 | public suspend fun ascii2d(url: String, bovw: Boolean): List<SearchResult> { 205 | val response: HttpResponse = http { client -> 206 | client.get("https://ascii2d.net/search/url/${url}") 207 | } 208 | 209 | val html: String = if (bovw) { 210 | http { client -> 211 | // https://ascii2d.net/search/color -> https://ascii2d.net/search/bovw 212 | client.get(response.request.url.toString().replace("color", "bovw")) 213 | }.body() 214 | } else { 215 | response.body() 216 | } 217 | 218 | return ascii2d(Jsoup.parse(html)) 219 | } 220 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/tools/NaviRank.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.tools 2 | 3 | import io.ktor.http.* 4 | import org.jsoup.nodes.* 5 | import xyz.cssxsh.mirai.pixiv.model.* 6 | import xyz.cssxsh.pixiv.* 7 | import java.time.* 8 | import java.time.format.* 9 | 10 | public object NaviRank : HtmlParser(name = "NaviRank") { 11 | 12 | private const val API = "http://pixiv.navirank.com/" 13 | 14 | private val type: (String) -> WorkContentType = { 15 | when { 16 | it.startsWith("イラスト") -> WorkContentType.ILLUST 17 | it.startsWith("うごく") -> WorkContentType.UGOIRA 18 | it.startsWith("漫画") -> WorkContentType.MANGA 19 | else -> throw IllegalArgumentException("未知类型 $it") 20 | } 21 | } 22 | 23 | private val NUM = """\d+""".toRegex() 24 | 25 | private val record: (Element) -> NaviRankRecord = { element -> 26 | NaviRankRecord( 27 | // index = element.select(".num").text().toInt(), 28 | pid = element.select(".title").findAll(NUM).first().value.toLong(), 29 | title = element.select(".title").text().trim(), 30 | type = element.select(".type").text().let(type), 31 | page = element.select(".type").findAll(NUM).firstOrNull()?.value?.toInt() ?: 1, 32 | date = element.select(".date").text().let { LocalDate.parse(it) }, 33 | uid = element.select(".user_name").findAll(NUM).first().value.toLong(), 34 | name = element.select(".user_name").text().trim(), 35 | tags = element.select(".tag").map { it.text().trim() } 36 | ) 37 | } 38 | 39 | private val all: (Document) -> NaviRankAllTime = { document -> 40 | NaviRankAllTime( 41 | title = document.select("#cheader").text(), 42 | records = document.select(".irank").map(record) 43 | ) 44 | } 45 | 46 | private val over: (Document) -> NaviRankOverTime = { document -> 47 | NaviRankOverTime( 48 | title = document.select("#cheader").text(), 49 | records = document.select("#over").associate { element -> 50 | element.select("h3").text() to element.select(".irank").map(record) 51 | } 52 | ) 53 | } 54 | 55 | public val START: YearMonth = YearMonth.of(2008, 5) 56 | 57 | private fun Year.path() = format(DateTimeFormatter.ofPattern("yyyy")) 58 | 59 | private fun YearMonth.path() = format(DateTimeFormatter.ofPattern("yyyy/MM")) 60 | 61 | private suspend fun all(path: String) = html(all) { 62 | url { 63 | takeFrom(API) 64 | encodedPath = "/all/${path}" 65 | } 66 | method = HttpMethod.Get 67 | } 68 | 69 | public suspend fun getAllRank(): NaviRankAllTime = all(path = "") 70 | 71 | public suspend fun getAllRank(year: Year): NaviRankAllTime = all(path = year.path()) 72 | 73 | public suspend fun getAllRank(month: YearMonth): NaviRankAllTime = all(path = month.path()) 74 | 75 | private suspend fun over(path: String) = html(over) { 76 | url { 77 | takeFrom(API) 78 | encodedPath = "/over/${path}" 79 | } 80 | method = HttpMethod.Get 81 | } 82 | 83 | public suspend fun getOverRank(): NaviRankOverTime = over(path = "") 84 | 85 | public suspend fun getOverRank(year: Year): NaviRankOverTime = over(path = year.path()) 86 | 87 | public suspend fun getOverRank(month: YearMonth): NaviRankOverTime = over(path = month.path()) 88 | 89 | private suspend fun tag(path: String, vararg words: String) = html(all) { 90 | check(words.isNotEmpty()) { "关键词不能为空" } 91 | url { 92 | takeFrom(API) 93 | encodedPath = "/tag/${words.joinToString("%0A")}/${path}" 94 | } 95 | method = HttpMethod.Get 96 | } 97 | 98 | public suspend fun getTagRank(vararg words: String): NaviRankAllTime = tag(path = "", words = words) 99 | 100 | public suspend fun getTagRank(year: Year, vararg words: String): NaviRankAllTime = 101 | tag(path = year.path(), words = words) 102 | 103 | public suspend fun getTagRank(month: YearMonth, vararg words: String): NaviRankAllTime = 104 | tag(path = month.path(), words = words) 105 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/pixiv/tools/Pixivision.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.tools 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.http.* 5 | import org.jsoup.* 6 | import org.jsoup.nodes.* 7 | import org.jsoup.safety.* 8 | import xyz.cssxsh.mirai.pixiv.model.* 9 | import java.util.* 10 | 11 | public object Pixivision : HtmlParser(name = "Pixivision") { 12 | 13 | private const val API = "https://www.pixivision.net/" 14 | 15 | private val settings = Document.OutputSettings().prettyPrint(false) 16 | 17 | private fun Element.doc(): String { 18 | return Jsoup.clean(html(), "", Safelist.none(), settings) 19 | } 20 | 21 | private val article: (Document) -> PixivArticle = { document -> 22 | PixivArticle( 23 | title = document.select(".am__title").text(), 24 | description = document.select(".am__description").first()!!.doc(), 25 | illusts = document.select(".am__work").map { element -> 26 | PixivArticle.Illust( 27 | pid = element.select(".am__work__title a").first()!! 28 | .href().substringAfterLast("/").toLong(), 29 | title = element.select(".am__work__title a").first()!! 30 | .text(), 31 | uid = element.select(".am__work__user-name a").first()!! 32 | .href().substringAfterLast("/").toLong(), 33 | name = element.select(".am__work__user-name a").first()!! 34 | .text() 35 | ) 36 | } 37 | ) 38 | } 39 | 40 | public suspend fun getArticle(aid: Long, locale: Locale = Locale.CHINA): PixivArticle = html(article) { 41 | url { 42 | takeFrom(API) 43 | encodedPath = "/${locale.language}/a/${aid}" 44 | } 45 | method = HttpMethod.Get 46 | header(HttpHeaders.AcceptLanguage, locale.language) 47 | header(HttpHeaders.Referrer, url) 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/net.mamoe.mirai.console.plugin.jvm.JvmPlugin: -------------------------------------------------------------------------------- 1 | xyz.cssxsh.mirai.pixiv.PixivHelperPlugin -------------------------------------------------------------------------------- /src/main/resources/xyz/cssxsh/mirai/pixiv/model/create.h2.sql: -------------------------------------------------------------------------------- 1 | -- Illust Data 2 | CREATE TABLE IF NOT EXISTS `users` 3 | ( 4 | `uid` INTEGER NOT NULL, 5 | `name` VARCHAR(15) NOT NULL, 6 | `account` VARCHAR(32) DEFAULT NULL, 7 | PRIMARY KEY (`uid`), 8 | UNIQUE (`account`) 9 | ); 10 | CREATE TABLE IF NOT EXISTS `artworks` 11 | ( 12 | `pid` INTEGER NOT NULL, 13 | `uid` INTEGER NOT NULL, 14 | `title` TINYTEXT NOT NULL, 15 | `caption` TEXT NOT NULL, 16 | `create_at` INTEGER NOT NULL, 17 | `page_count` TINYINT NOT NULL, 18 | `sanity_level` TINYINT NOT NULL, 19 | `type` TINYINT NOT NULL, 20 | `width` SMALLINT NOT NULL, 21 | `height` SMALLINT NOT NULL, 22 | `total_bookmarks` INTEGER NOT NULL DEFAULT 0, 23 | `total_comments` INTEGER NOT NULL DEFAULT 0, 24 | `total_view` INTEGER NOT NULL DEFAULT 0, 25 | `age` TINYINT NOT NULL DEFAULT 0, 26 | `is_ero` BOOLEAN NOT NULL DEFAULT FALSE, 27 | `deleted` BOOLEAN NOT NULL DEFAULT FALSE, 28 | PRIMARY KEY (`pid`), 29 | FOREIGN KEY (`uid`) REFERENCES `users` (`uid`) ON UPDATE CASCADE ON DELETE CASCADE 30 | ); 31 | CREATE INDEX IF NOT EXISTS `user_index` ON `artworks` (`uid`); 32 | CREATE TABLE IF NOT EXISTS `tag` 33 | ( 34 | `name` VARCHAR(50) NOT NULL, 35 | `translated_name` TEXT DEFAULT NULL, 36 | `tid` BIGINT NOT NULL AUTO_INCREMENT, 37 | PRIMARY KEY (`name`), 38 | UNIQUE (`tid`) 39 | ); 40 | CREATE TABLE IF NOT EXISTS `artwork_tag` 41 | ( 42 | `pid` INTEGER NOT NULL, 43 | `tid` INTEGER NOT NULL, 44 | FOREIGN KEY (`pid`) REFERENCES `artworks` (`pid`) ON UPDATE CASCADE ON DELETE CASCADE, 45 | FOREIGN KEY (`tid`) REFERENCES `tag` (`tid`) ON UPDATE CASCADE ON DELETE CASCADE 46 | ); 47 | CREATE TABLE IF NOT EXISTS `files` 48 | ( 49 | `pid` INTEGER NOT NULL, 50 | `index` SMALLINT NOT NULL, 51 | `md5` CHAR(32) NOT NULL, 52 | `url` TEXT NOT NULL, 53 | -- file size max 32MB 54 | `size` INTEGER NOT NULL, 55 | PRIMARY KEY (`pid`, `index`), 56 | FOREIGN KEY (`pid`) REFERENCES `artworks` (`pid`) ON UPDATE CASCADE ON DELETE CASCADE 57 | ); 58 | CREATE INDEX IF NOT EXISTS `md5_index` ON `files` (`md5`); 59 | CREATE TABLE IF NOT EXISTS `twitter` 60 | ( 61 | `screen` VARCHAR(15) NOT NULL, 62 | `uid` INTEGER NOT NULL, 63 | PRIMARY KEY (`screen`) 64 | ); 65 | 66 | -- User Data 67 | CREATE TABLE IF NOT EXISTS `statistic_ero` 68 | ( 69 | `sender` INTEGER NOT NULL, 70 | `group` INTEGER, 71 | `pid` INTEGER NOT NULL, 72 | `timestamp` INTEGER NOT NULL, 73 | PRIMARY KEY (`sender`, `timestamp`) 74 | ); 75 | CREATE TABLE IF NOT EXISTS `statistic_tag` 76 | ( 77 | `sender` INTEGER NOT NULL, 78 | `group` INTEGER, 79 | `pid` INTEGER, 80 | `tag` VARCHAR(30) NOT NULL, 81 | `timestamp` INTEGER NOT NULL, 82 | PRIMARY KEY (`sender`, `timestamp`) 83 | ); 84 | CREATE TABLE IF NOT EXISTS `statistic_search` 85 | ( 86 | `md5` CHAR(32) NOT NULL, 87 | `similarity` NUMERIC(6, 4) NOT NULL, 88 | `pid` INTEGER NOT NULL, 89 | `title` VARCHAR(64) NOT NULL, 90 | `uid` INTEGER NOT NULL, 91 | `name` VARCHAR(15) NOT NULL, 92 | PRIMARY KEY (`md5`) 93 | ); 94 | CREATE TABLE IF NOT EXISTS `statistic_alias` 95 | ( 96 | `name` VARCHAR(15) NOT NULL, 97 | `uid` INTEGER NOT NULL, 98 | PRIMARY KEY (`name`) 99 | ); 100 | CREATE TABLE IF NOT EXISTS `statistic_task` 101 | ( 102 | `task` VARCHAR(64) NOT NULL, 103 | `pid` INTEGER NOT NULL, 104 | `timestamp` INTEGER NOT NULL, 105 | PRIMARY KEY (`task`, `pid`) 106 | ); 107 | 108 | -- view 109 | CREATE OR REPLACE VIEW `statistic_user` AS 110 | SELECT `uid`, COUNT(*) AS `count`, COUNT(`is_ero` OR null) AS `ero` 111 | FROM `artworks` 112 | WHERE NOT `deleted` 113 | GROUP BY `uid`; -------------------------------------------------------------------------------- /src/main/resources/xyz/cssxsh/mirai/pixiv/model/create.mysql.sql: -------------------------------------------------------------------------------- 1 | -- Illust Data 2 | CREATE TABLE IF NOT EXISTS `users` 3 | ( 4 | `uid` INTEGER UNSIGNED NOT NULL, 5 | `name` VARCHAR(15) NOT NULL COLLATE 'utf8mb4_unicode_ci', 6 | `account` VARCHAR(32) DEFAULT NULL COLLATE 'ascii_general_ci', 7 | PRIMARY KEY (`uid`), 8 | UNIQUE (`account`) 9 | ) DEFAULT CHARACTER SET 'utf8mb4'; 10 | CREATE TABLE IF NOT EXISTS `artworks` 11 | ( 12 | `pid` INTEGER UNSIGNED NOT NULL, 13 | `uid` INTEGER UNSIGNED NOT NULL, 14 | `title` TINYTEXT NOT NULL, 15 | `caption` TEXT NOT NULL, 16 | `create_at` INTEGER UNSIGNED NOT NULL, 17 | -- page_count max 200 18 | `page_count` TINYINT UNSIGNED NOT NULL, 19 | -- sanity_level 0 2 4 6 7 20 | `sanity_level` TINYINT UNSIGNED NOT NULL, 21 | `type` TINYINT UNSIGNED NOT NULL, 22 | `width` SMALLINT UNSIGNED NOT NULL, 23 | `height` SMALLINT UNSIGNED NOT NULL, 24 | `total_bookmarks` INTEGER UNSIGNED NOT NULL DEFAULT 0, 25 | `total_comments` INTEGER UNSIGNED NOT NULL DEFAULT 0, 26 | `total_view` INTEGER UNSIGNED NOT NULL DEFAULT 0, 27 | `age` TINYINT UNSIGNED NOT NULL DEFAULT 0, 28 | `is_ero` BOOLEAN NOT NULL DEFAULT FALSE, 29 | `deleted` BOOLEAN NOT NULL DEFAULT FALSE, 30 | PRIMARY KEY (`pid`), 31 | FOREIGN KEY (`uid`) REFERENCES `users` (`uid`) ON UPDATE CASCADE ON DELETE CASCADE, 32 | INDEX (`uid`) 33 | ) DEFAULT CHARACTER SET 'utf8mb4'; 34 | CREATE TABLE IF NOT EXISTS `tag` 35 | ( 36 | `name` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_bin', 37 | `translated_name` TINYTEXT DEFAULT NULL COLLATE 'utf8mb4_unicode_ci', 38 | `tid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, 39 | PRIMARY KEY (`name`), 40 | UNIQUE (`tid`) 41 | ) DEFAULT CHARACTER SET 'utf8mb4'; 42 | CREATE TABLE IF NOT EXISTS `artwork_tag` 43 | ( 44 | `pid` INTEGER UNSIGNED NOT NULL, 45 | `tid` INTEGER UNSIGNED NOT NULL, 46 | FOREIGN KEY (`pid`) REFERENCES `artworks` (`pid`) ON UPDATE CASCADE ON DELETE CASCADE, 47 | FOREIGN KEY (`tid`) REFERENCES `tag` (`tid`) ON UPDATE CASCADE ON DELETE CASCADE 48 | ) DEFAULT CHARACTER SET 'utf8mb4'; 49 | CREATE TABLE IF NOT EXISTS `files` 50 | ( 51 | `pid` INTEGER UNSIGNED NOT NULL, 52 | `index` TINYINT UNSIGNED NOT NULL, 53 | `md5` CHAR(32) NOT NULL COLLATE 'ascii_general_ci', 54 | `url` TINYTEXT NOT NULL COLLATE 'ascii_general_ci', 55 | -- file size max 32MB 56 | `size` INTEGER UNSIGNED NOT NULL, 57 | PRIMARY KEY (`pid`, `index`), 58 | FOREIGN KEY (`pid`) REFERENCES `artworks` (`pid`) ON UPDATE CASCADE ON DELETE CASCADE, 59 | INDEX (`md5`) 60 | ); 61 | CREATE TABLE IF NOT EXISTS `twitter` 62 | ( 63 | `screen` VARCHAR(15) NOT NULL COLLATE 'ascii_general_ci', 64 | `uid` INTEGER UNSIGNED NOT NULL, 65 | PRIMARY KEY (`screen`) 66 | ); 67 | 68 | -- User Data 69 | CREATE TABLE IF NOT EXISTS `statistic_ero` 70 | ( 71 | `sender` INTEGER UNSIGNED NOT NULL, 72 | `group` INTEGER UNSIGNED, 73 | `pid` INTEGER UNSIGNED NOT NULL, 74 | `timestamp` INTEGER UNSIGNED NOT NULL, 75 | PRIMARY KEY (`sender`, `timestamp`) 76 | ); 77 | CREATE TABLE IF NOT EXISTS `statistic_tag` 78 | ( 79 | `sender` INTEGER UNSIGNED NOT NULL, 80 | `group` INTEGER UNSIGNED, 81 | `pid` INTEGER UNSIGNED, 82 | `tag` VARCHAR(30) NOT NULL, 83 | `timestamp` INTEGER UNSIGNED NOT NULL, 84 | PRIMARY KEY (`sender`, `timestamp`) 85 | ); 86 | CREATE TABLE IF NOT EXISTS `statistic_search` 87 | ( 88 | `md5` CHAR(32) NOT NULL COLLATE 'ascii_general_ci', 89 | `similarity` NUMERIC(6, 4) NOT NULL, 90 | `pid` INTEGER UNSIGNED NOT NULL, 91 | `title` VARCHAR(64) NOT NULL, 92 | `uid` INTEGER UNSIGNED NOT NULL, 93 | `name` VARCHAR(15) NOT NULL, 94 | PRIMARY KEY (`md5`) 95 | ) DEFAULT CHARACTER SET 'utf8mb4'; 96 | CREATE TABLE IF NOT EXISTS `statistic_alias` 97 | ( 98 | `name` VARCHAR(15) NOT NULL, 99 | `uid` INTEGER UNSIGNED NOT NULL, 100 | PRIMARY KEY (`name`) 101 | ); 102 | CREATE TABLE IF NOT EXISTS `statistic_task` 103 | ( 104 | `task` VARCHAR(64) NOT NULL COLLATE 'utf8mb4_unicode_ci', 105 | `pid` INTEGER UNSIGNED NOT NULL, 106 | `timestamp` INTEGER UNSIGNED NOT NULL, 107 | PRIMARY KEY (`task`, `pid`) 108 | ); 109 | 110 | -- view 111 | CREATE OR REPLACE VIEW `statistic_user` AS 112 | SELECT `uid`, COUNT(*) AS `count`, COUNT(is_ero OR null) AS `ero` 113 | FROM `artworks` 114 | WHERE NOT `deleted` 115 | GROUP BY `uid`; -------------------------------------------------------------------------------- /src/main/resources/xyz/cssxsh/mirai/pixiv/model/create.postgresql.sql: -------------------------------------------------------------------------------- 1 | -- Illust Data 2 | CREATE TABLE IF NOT EXISTS "users" 3 | ( 4 | "uid" INTEGER NOT NULL, 5 | "name" VARCHAR(15) NOT NULL, 6 | "account" VARCHAR(32) DEFAULT NULL, 7 | PRIMARY KEY ("uid"), 8 | UNIQUE ("account") 9 | ); 10 | CREATE TABLE IF NOT EXISTS "artworks" 11 | ( 12 | "pid" INTEGER NOT NULL, 13 | "uid" INTEGER NOT NULL, 14 | "title" TEXT NOT NULL, 15 | "caption" TEXT NOT NULL, 16 | "create_at" INTEGER NOT NULL, 17 | -- page_count max 200 18 | "page_count" SMALLINT NOT NULL, 19 | -- sanity_level 0 2 4 6 7 20 | "sanity_level" SMALLINT NOT NULL, 21 | "type" SMALLINT NOT NULL, 22 | "width" SMALLINT NOT NULL, 23 | "height" SMALLINT NOT NULL, 24 | "total_bookmarks" INTEGER NOT NULL DEFAULT 0, 25 | "total_comments" INTEGER NOT NULL DEFAULT 0, 26 | "total_view" INTEGER NOT NULL DEFAULT 0, 27 | "age" SMALLINT NOT NULL DEFAULT 0, 28 | "is_ero" BOOLEAN NOT NULL DEFAULT FALSE, 29 | "deleted" BOOLEAN NOT NULL DEFAULT FALSE, 30 | PRIMARY KEY ("pid"), 31 | FOREIGN KEY ("uid") REFERENCES "users" ("uid") ON UPDATE CASCADE ON DELETE CASCADE 32 | ); 33 | CREATE INDEX IF NOT EXISTS "user_index" ON "artworks" ("uid"); 34 | CREATE TABLE IF NOT EXISTS "tag" 35 | ( 36 | "name" VARCHAR(50) NOT NULL, 37 | "translated_name" TEXT DEFAULT NULL, 38 | "tid" SERIAL NOT NULL, 39 | PRIMARY KEY ("name"), 40 | UNIQUE ("tid") 41 | ); 42 | CREATE TABLE IF NOT EXISTS "artwork_tag" 43 | ( 44 | "pid" INTEGER NOT NULL, 45 | "tid" INTEGER NOT NULL, 46 | FOREIGN KEY ("pid") REFERENCES "artworks" ("pid") ON UPDATE CASCADE ON DELETE CASCADE, 47 | FOREIGN KEY ("tid") REFERENCES "tag" ("tid") ON UPDATE CASCADE ON DELETE CASCADE 48 | ); 49 | CREATE TABLE IF NOT EXISTS "files" 50 | ( 51 | "pid" INTEGER NOT NULL, 52 | "index" SMALLINT NOT NULL, 53 | "md5" CHAR(32) NOT NULL, 54 | "url" TEXT NOT NULL, 55 | -- file size max 32MB 56 | "size" INTEGER NOT NULL, 57 | PRIMARY KEY ("pid", "index"), 58 | FOREIGN KEY ("pid") REFERENCES "artworks" ("pid") ON UPDATE CASCADE ON DELETE CASCADE 59 | ); 60 | CREATE INDEX IF NOT EXISTS "md5_index" ON "files" ("md5"); 61 | CREATE TABLE IF NOT EXISTS "twitter" 62 | ( 63 | "screen" VARCHAR(15) NOT NULL, 64 | "uid" INTEGER NOT NULL, 65 | PRIMARY KEY ("screen") 66 | ); 67 | 68 | -- User Data 69 | CREATE TABLE IF NOT EXISTS "statistic_ero" 70 | ( 71 | "sender" INTEGER NOT NULL, 72 | "group" INTEGER, 73 | "pid" INTEGER NOT NULL, 74 | "timestamp" INTEGER NOT NULL, 75 | PRIMARY KEY ("sender", "timestamp") 76 | ); 77 | CREATE TABLE IF NOT EXISTS "statistic_tag" 78 | ( 79 | "sender" INTEGER NOT NULL, 80 | "group" INTEGER, 81 | "pid" INTEGER, 82 | "tag" VARCHAR(30) NOT NULL, 83 | "timestamp" INTEGER NOT NULL, 84 | PRIMARY KEY ("sender", "timestamp") 85 | ); 86 | CREATE TABLE IF NOT EXISTS "statistic_search" 87 | ( 88 | "md5" CHAR(32) NOT NULL, 89 | "similarity" NUMERIC(6, 4) NOT NULL, 90 | "pid" INTEGER NOT NULL, 91 | "title" VARCHAR(64) NOT NULL, 92 | "uid" INTEGER NOT NULL, 93 | "name" VARCHAR(15) NOT NULL, 94 | PRIMARY KEY ("md5") 95 | ); 96 | CREATE TABLE IF NOT EXISTS "statistic_alias" 97 | ( 98 | "name" VARCHAR(15) NOT NULL, 99 | "uid" INTEGER NOT NULL, 100 | PRIMARY KEY ("name") 101 | ); 102 | CREATE TABLE IF NOT EXISTS "statistic_task" 103 | ( 104 | "task" VARCHAR(64) NOT NULL, 105 | "pid" INTEGER NOT NULL, 106 | "timestamp" INTEGER NOT NULL, 107 | PRIMARY KEY ("task", "pid") 108 | ); 109 | 110 | -- view 111 | CREATE OR REPLACE VIEW "statistic_user" AS 112 | SELECT "uid", COUNT(*) AS "count", COUNT("is_ero" OR null) AS "ero" 113 | FROM "artworks" 114 | WHERE NOT "deleted" 115 | GROUP BY "uid"; -------------------------------------------------------------------------------- /src/main/resources/xyz/cssxsh/mirai/pixiv/model/create.sqlite.sql: -------------------------------------------------------------------------------- 1 | -- Illust Data 2 | CREATE TABLE IF NOT EXISTS users 3 | ( 4 | `uid` INTEGER NOT NULL, 5 | `name` TEXT NOT NULL COLLATE RTRIM, 6 | `account` TEXT DEFAULT NULL COLLATE RTRIM, 7 | PRIMARY KEY (`uid`), 8 | UNIQUE (`account`) 9 | ); 10 | CREATE TABLE IF NOT EXISTS artworks 11 | ( 12 | `pid` INTEGER NOT NULL, 13 | `uid` INTEGER NOT NULL, 14 | `title` TEXT NOT NULL, 15 | `caption` TEXT NOT NULL, 16 | `create_at` INTEGER NOT NULL, 17 | -- page_count max 200 18 | `page_count` SMALLINT NOT NULL, 19 | -- sanity_level 0 2 4 6 7 20 | `sanity_level` TINYINT NOT NULL, 21 | `type` TINYINT NOT NULL, 22 | `width` SMALLINT NOT NULL, 23 | `height` SMALLINT NOT NULL, 24 | `total_bookmarks` INTEGER NOT NULL DEFAULT 0, 25 | `total_comments` INTEGER NOT NULL DEFAULT 0, 26 | `total_view` INTEGER NOT NULL DEFAULT 0, 27 | `age` TINYINT NOT NULL DEFAULT 0, 28 | `is_ero` BOOLEAN NOT NULL DEFAULT FALSE, 29 | `deleted` BOOLEAN NOT NULL DEFAULT FALSE, 30 | PRIMARY KEY (`pid`), 31 | FOREIGN KEY (`uid`) REFERENCES users (`uid`) ON UPDATE CASCADE ON DELETE CASCADE 32 | ); 33 | CREATE INDEX IF NOT EXISTS user_id ON users (`uid`); 34 | CREATE TABLE IF NOT EXISTS tag 35 | ( 36 | `name` TEXT NOT NULL COLLATE RTRIM, 37 | `translated_name` TEXT DEFAULT NULL COLLATE NOCASE, 38 | `tid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 39 | UNIQUE (`name`) 40 | ); 41 | CREATE TABLE IF NOT EXISTS artwork_tag 42 | ( 43 | `pid` INTEGER UNSIGNED NOT NULL, 44 | `tid` INTEGER UNSIGNED NOT NULL, 45 | FOREIGN KEY (`pid`) REFERENCES artworks (`pid`) ON UPDATE CASCADE ON DELETE CASCADE, 46 | FOREIGN KEY (`tid`) REFERENCES tag (`tid`) ON UPDATE CASCADE ON DELETE CASCADE 47 | ); 48 | CREATE TABLE IF NOT EXISTS files 49 | ( 50 | `pid` INTEGER NOT NULL, 51 | `index` TINYINT NOT NULL, 52 | `md5` TEXT NOT NULL COLLATE NOCASE, 53 | `url` TEXT NOT NULL COLLATE NOCASE, 54 | -- file size max 32MB 55 | `size` INTEGER NOT NULL, 56 | PRIMARY KEY (`pid`, `index`), 57 | FOREIGN KEY (`pid`) REFERENCES artworks (`pid`) ON UPDATE CASCADE ON DELETE CASCADE 58 | ); 59 | CREATE INDEX IF NOT EXISTS file_md5 ON files (`md5`); 60 | CREATE TABLE IF NOT EXISTS twitter 61 | ( 62 | `screen` TEXT NOT NULL COLLATE NOCASE, 63 | `uid` INTEGER NOT NULL, 64 | PRIMARY KEY (`screen`) 65 | ); 66 | 67 | -- User Data 68 | CREATE TABLE IF NOT EXISTS statistic_ero 69 | ( 70 | `sender` INTEGER NOT NULL, 71 | `group` INTEGER, 72 | `pid` INTEGER NOT NULL, 73 | `timestamp` INTEGER NOT NULL, 74 | PRIMARY KEY (`sender`, `timestamp`) 75 | ); 76 | CREATE TABLE IF NOT EXISTS statistic_tag 77 | ( 78 | `sender` INTEGER NOT NULL, 79 | `group` INTEGER, 80 | `pid` INTEGER, 81 | `tag` TEXT NOT NULL COLLATE RTRIM, 82 | `timestamp` INTEGER NOT NULL, 83 | PRIMARY KEY (`sender`, `timestamp`) 84 | ); 85 | CREATE TABLE IF NOT EXISTS statistic_search 86 | ( 87 | `md5` TEXT NOT NULL COLLATE NOCASE, 88 | `similarity` REAL NOT NULL, 89 | `pid` INTEGER NOT NULL, 90 | `title` TEXT NOT NULL, 91 | `uid` INTEGER NOT NULL, 92 | `name` TEXT NOT NULL, 93 | PRIMARY KEY (`md5`) 94 | ); 95 | CREATE TABLE IF NOT EXISTS statistic_alias 96 | ( 97 | `name` TEXT NOT NULL COLLATE RTRIM, 98 | `uid` INTEGER NOT NULL, 99 | PRIMARY KEY (`name`) 100 | ); 101 | CREATE TABLE IF NOT EXISTS statistic_task 102 | ( 103 | `task` TEXT NOT NULL COLLATE NOCASE, 104 | `pid` INTEGER NOT NULL, 105 | `timestamp` INTEGER NOT NULL, 106 | PRIMARY KEY (`task`, `pid`) 107 | ); 108 | 109 | -- view 110 | CREATE VIEW IF NOT EXISTS statistic_user AS 111 | SELECT `uid`, COUNT(*) AS `count`, COUNT(is_ero OR null) AS `ero` 112 | FROM artworks 113 | WHERE NOT `deleted` 114 | GROUP BY `uid`; -------------------------------------------------------------------------------- /src/main/resources/xyz/cssxsh/mirai/pixiv/model/create.sqlserver.sql: -------------------------------------------------------------------------------- 1 | -- Illust Data 2 | IF NOT EXISTS(SELECT [name] 3 | FROM sys.tables 4 | WHERE [name] = 'users') 5 | CREATE TABLE [users] 6 | ( 7 | [uid] INTEGER NOT NULL, 8 | [name] NVARCHAR(15) NOT NULL COLLATE LATIN1_100_CI_AI_UTF8, 9 | [account] VARCHAR(32) DEFAULT NULL COLLATE LATIN1_100_BIN, 10 | PRIMARY KEY ([uid]), 11 | UNIQUE ([account]) 12 | ); 13 | IF NOT EXISTS(SELECT [name] 14 | FROM sys.tables 15 | WHERE [name] = 'artworks') 16 | CREATE TABLE [artworks] 17 | ( 18 | [pid] INTEGER NOT NULL, 19 | [uid] INTEGER NOT NULL, 20 | [title] NVARCHAR(64) NOT NULL, 21 | [caption] VARCHAR(MAX) NOT NULL, 22 | [create_at] INTEGER NOT NULL, 23 | -- page_count max 200 24 | [page_count] SMALLINT NOT NULL, 25 | -- sanity_level 0 2 4 6 7 26 | [sanity_level] TINYINT NOT NULL, 27 | [type] TINYINT NOT NULL, 28 | [width] SMALLINT NOT NULL, 29 | [height] SMALLINT NOT NULL, 30 | [total_bookmarks] INTEGER NOT NULL DEFAULT 0, 31 | [total_comments] INTEGER NOT NULL DEFAULT 0, 32 | [total_view] INTEGER NOT NULL DEFAULT 0, 33 | [age] TINYINT NOT NULL DEFAULT 0, 34 | [is_ero] BIT NOT NULL DEFAULT FALSE, 35 | [deleted] BIT NOT NULL DEFAULT FALSE, 36 | PRIMARY KEY ([pid]), 37 | FOREIGN KEY ([uid]) REFERENCES [users] ([uid]) ON UPDATE CASCADE ON DELETE CASCADE, 38 | INDEX [user_id] ([uid]) 39 | ); 40 | IF NOT EXISTS(SELECT [name] 41 | FROM sys.tables 42 | WHERE [name] = 'tag') 43 | CREATE TABLE [tag] 44 | ( 45 | [name] VARCHAR(50) NOT NULL COLLATE LATIN1_100_BIN, 46 | [translated_name] VARCHAR(MAX) DEFAULT NULL COLLATE 'utf8mb4_bin', 47 | [tid] INTEGER NOT NULL IDENTITY (0,1), 48 | PRIMARY KEY ([name]), 49 | UNIQUE ([tid]) 50 | ); 51 | IF NOT EXISTS(SELECT [name] 52 | FROM sys.tables 53 | WHERE [name] = 'artwork_tag') 54 | CREATE TABLE [artwork_tag] 55 | ( 56 | [pid] INTEGER NOT NULL, 57 | [tid] INTEGER NOT NULL, 58 | FOREIGN KEY ([pid]) REFERENCES [artworks] ([pid]) ON UPDATE CASCADE ON DELETE CASCADE, 59 | FOREIGN KEY ([tid]) REFERENCES [tag] ([tid]) ON UPDATE CASCADE ON DELETE CASCADE 60 | ); 61 | IF NOT EXISTS(SELECT [name] 62 | FROM sys.tables 63 | WHERE [name] = 'files') 64 | CREATE TABLE [files] 65 | ( 66 | [pid] INTEGER NOT NULL, 67 | [index] TINYINT NOT NULL, 68 | [md5] CHAR(32) NOT NULL COLLATE LATIN1_100_CI_AI, 69 | [url] VARCHAR(255) NOT NULL COLLATE LATIN1_100_CI_AI, 70 | -- file size max 32MB size INTEGER NOT NULL, 71 | PRIMARY KEY ([pid], [index]), 72 | FOREIGN KEY ([pid]) REFERENCES [artworks] ([pid]) ON UPDATE CASCADE ON DELETE CASCADE, 73 | INDEX [file_md5] ([md5]) 74 | ); 75 | IF NOT EXISTS(SELECT [name] 76 | FROM sys.tables 77 | WHERE [name] = 'twitter') 78 | CREATE TABLE [twitter] 79 | ( 80 | [screen] VARCHAR(15) NOT NULL COLLATE LATIN1_100_CI_AI, 81 | [uid] INTEGER NOT NULL, 82 | PRIMARY KEY ([screen]) 83 | ); 84 | 85 | -- User Data 86 | IF NOT EXISTS(SELECT [name] 87 | FROM sys.tables 88 | WHERE [name] = 'statistic_ero') 89 | CREATE TABLE [statistic_ero] 90 | ( 91 | [sender] BIGINT NOT NULL, 92 | [group] INTEGER, 93 | [pid] INTEGER NOT NULL, 94 | [timestamp] INTEGER NOT NULL, 95 | PRIMARY KEY ([sender], [timestamp]) 96 | ); 97 | IF NOT EXISTS(SELECT [name] 98 | FROM sys.tables 99 | WHERE [name] = 'statistic_tag') 100 | CREATE TABLE [statistic_tag] 101 | ( 102 | [sender] BIGINT NOT NULL, 103 | [group] INTEGER, 104 | [pid] INTEGER, 105 | [tag] NVARCHAR(30) NOT NULL COLLATE LATIN1_100_CI_AI_UTF8, 106 | [timestamp] INTEGER NOT NULL, 107 | PRIMARY KEY ([sender], [timestamp]) 108 | ); 109 | IF NOT EXISTS(SELECT [name] 110 | FROM sys.tables 111 | WHERE [name] = 'statistic_search') 112 | CREATE TABLE [statistic_search] 113 | ( 114 | [md5] CHAR(32) NOT NULL COLLATE LATIN1_100_CI_AI, 115 | [similarity] NUMERIC(6, 4) NOT NULL, 116 | [pid] INTEGER NOT NULL, 117 | [title] NVARCHAR(64) NOT NULL, 118 | [uid] INTEGER NOT NULL, 119 | [name] NVARCHAR(15) NOT NULL, 120 | PRIMARY KEY ([md5]) 121 | ); 122 | IF NOT EXISTS(SELECT [name] 123 | FROM sys.tables 124 | WHERE [name] = 'statistic_alias') 125 | CREATE TABLE [statistic_alias] 126 | ( 127 | [name] NVARCHAR(15) NOT NULL COLLATE LATIN1_100_CI_AI_UTF8, 128 | [uid] INTEGER NOT NULL, 129 | PRIMARY KEY ([name]) 130 | ); 131 | IF NOT EXISTS(SELECT [name] 132 | FROM sys.tables 133 | WHERE [name] = 'statistic_task') 134 | CREATE TABLE [statistic_task] 135 | ( 136 | [task] VARCHAR(64) NOT NULL COLLATE LATIN1_100_CI_AI_UTF8, 137 | [pid] INTEGER NOT NULL, 138 | [timestamp] INTEGER NOT NULL, 139 | PRIMARY KEY ([task], [pid]) 140 | ); 141 | 142 | -- view 143 | IF NOT EXISTS(SELECT [name] 144 | FROM sys.views 145 | WHERE [name] = 'statistic_user') 146 | CREATE VIEW [statistic_user] AS 147 | SELECT [uid], COUNT(*) AS [count], COUNT([is_ero] OR null) AS [ero] 148 | FROM [artworks] 149 | WHERE NOT [deleted] 150 | GROUP BY [uid]; -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/mirai/pixiv/tools/ImageSearcherTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.tools 2 | 3 | import kotlinx.coroutines.* 4 | import org.junit.jupiter.api.* 5 | 6 | internal class ImageSearcherTest { 7 | 8 | private val picUrl = "" 9 | 10 | @Test 11 | fun json(): Unit = runBlocking { 12 | ImageSearcher.json(url = picUrl).also { 13 | assert(it.isEmpty().not()) { "搜索结果为空" } 14 | }.forEach { 15 | println(it.toString()) 16 | } 17 | } 18 | 19 | private val twimg = "https://pbs.twimg.com/media/EaIpDtCVcAA85Hi?format=jpg&name=orig" 20 | 21 | @Test 22 | fun other(): Unit = runBlocking { 23 | ImageSearcher.html(url = twimg).also { 24 | assert(it.isEmpty().not()) { "搜索结果为空" } 25 | }.forEach { 26 | println(it) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/mirai/pixiv/tools/NaviRankTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.pixiv.tools 2 | 3 | import kotlinx.coroutines.runBlocking 4 | import org.junit.jupiter.api.Assertions.* 5 | import org.junit.jupiter.api.Test 6 | import java.time.Year 7 | import java.time.YearMonth 8 | 9 | internal class NaviRankTest { 10 | 11 | @Test 12 | fun getAllRank() = runBlocking { 13 | NaviRank.getAllRank().let { 14 | println(it) 15 | assertTrue(it.records.isNotEmpty()) 16 | } 17 | NaviRank.getAllRank(year = Year.now()).let { 18 | println(it) 19 | assertTrue(it.records.isNotEmpty()) 20 | } 21 | NaviRank.getAllRank(month = YearMonth.now()).let { 22 | println(it) 23 | assertTrue(it.records.isNotEmpty()) 24 | } 25 | } 26 | 27 | @Test 28 | fun getOverRank() = runBlocking { 29 | NaviRank.getOverRank().let { 30 | println(it) 31 | assertTrue(it.records.isNotEmpty()) 32 | } 33 | NaviRank.getOverRank(year = Year.now()).let { 34 | println(it) 35 | assertTrue(it.records.isNotEmpty()) 36 | } 37 | NaviRank.getOverRank(month = YearMonth.now()).let { 38 | println(it) 39 | assertTrue(it.records.isNotEmpty()) 40 | } 41 | } 42 | 43 | @Test 44 | fun getTagRank() = runBlocking { 45 | NaviRank.getTagRank("巨乳", "魅惑の谷間").let { 46 | println(it) 47 | assertTrue(it.records.isNotEmpty()) 48 | } 49 | NaviRank.getTagRank(year = Year.now(), "巨乳", "魅惑の谷間").let { 50 | println(it) 51 | assertTrue(it.records.isNotEmpty()) 52 | } 53 | NaviRank.getTagRank(month = YearMonth.now(), "巨乳", "魅惑の谷間").let { 54 | println(it) 55 | assertTrue(it.records.isNotEmpty()) 56 | } 57 | } 58 | } --------------------------------------------------------------------------------