diff --git a/.github/workflows/lib_build.yml b/.github/workflows/lib_build.yml index ac59d51bf5..0704c6fab9 100644 --- a/.github/workflows/lib_build.yml +++ b/.github/workflows/lib_build.yml @@ -36,6 +36,7 @@ jobs: - uses: actions/checkout@master with: fetch-depth: 0 + allow-unsafe-pr-checkout: true - name: Setup Go environment uses: actions/setup-go@master diff --git a/.github/workflows/lib_lint.yml b/.github/workflows/lib_lint.yml index 29bfacd181..3200b1eebf 100644 --- a/.github/workflows/lib_lint.yml +++ b/.github/workflows/lib_lint.yml @@ -36,6 +36,7 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: ${{ inputs.fetch-depth }} submodules: ${{ inputs.submodules }} + allow-unsafe-pr-checkout: true - name: Prepare Necessary Runtime Files run: | diff --git a/.github/workflows/lib_run.yml b/.github/workflows/lib_run.yml index 56be97e439..5db5ae5a24 100644 --- a/.github/workflows/lib_run.yml +++ b/.github/workflows/lib_run.yml @@ -38,6 +38,7 @@ jobs: ref: ${{ inputs.ref }} fetch-depth: ${{ inputs.fetch-depth }} submodules: ${{ inputs.submodules }} + allow-unsafe-pr-checkout: true - name: Prepare and Build shell: bash diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index af61750c1d..b061da8099 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1,11 +1,14 @@ name: PullLint on: + pull_request: + types: [opened, synchronize, reopened] pull_request_target: types: [assigned, opened, synchronize, reopened] jobs: # This workflow closes invalid PR close-pr: name: closepr + if: ${{ github.event_name == 'pull_request_target' }} # The type of runner that the job will run on runs-on: ubuntu-latest permissions: write-all @@ -20,8 +23,7 @@ jobs: comment: "非法PR. 请`fork`后修改自己的仓库, 而不是向主仓库提交更改. 如果您确信您的PR是为了给主仓库新增功能或修复bug, 请更改默认PR标题. **注意**: 如果您再次触发本提示, 则有可能导致账号被封禁." golangci: - needs: close-pr - if: ${{ !contains(github.event.pull_request.title, '.go') }} + if: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.title, '.go') }} uses: ./.github/workflows/lib_lint.yml with: ref: ${{ github.event.pull_request.head.sha }} @@ -29,13 +31,14 @@ jobs: runmain: needs: golangci - if: ${{ !contains(github.event.pull_request.title, '.go') }} + if: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.title, '.go') }} uses: ./.github/workflows/lib_run.yml with: ref: ${{ github.event.pull_request.head.sha }} build: needs: [runmain] + if: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.title, '.go') }} uses: ./.github/workflows/lib_build.yml with: prefix: "zbp_" diff --git a/README.md b/README.md index 6154dc7b28..124a1411ea 100644 --- a/README.md +++ b/README.md @@ -1223,6 +1223,32 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 解签 + +
+ Pixiv图片搜索 + + `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv"` + + - [x] [x张]涩图 [关键词] + + - [x] 每日涩图 + + - [x] [x张]画师[画师的uid] + + - [x] p站搜图[插画pid] + + - [x] 设置p站token [token] (仅私聊,超级用户) + + - [x] 授权此处使用p站18 (仅超级用户) + + - [x] 设置pixiv代理 [http://127.0.0.1:7890] (仅私聊,超级用户) + + - [x] 查看pixiv代理 (仅私聊,超级用户) + + - [x] 清除pixiv代理 (仅私聊,超级用户) + + - 注:[]不用打出来这只是一个占位符,可添加多个关键词每个关键词用空格隔开,默认不发R-18如果要发就加一个R-18关键词 +
抽扑克 diff --git a/go.mod b/go.mod index 713a4087a4..54d1b995f6 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,6 @@ require ( github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3 github.com/fumiama/go-registry v0.2.7 github.com/fumiama/gotracemoe v0.0.3 - github.com/fumiama/imgsz v0.0.4 github.com/fumiama/jieba v0.0.0-20221203025406-36c17a10b565 github.com/fumiama/slowdo v0.0.0-20241001074058-27c4fe5259a4 github.com/fumiama/unibase2n v0.0.0-20240530074540-ec743fd5a6d6 @@ -51,9 +50,11 @@ require ( golang.org/x/image v0.38.0 golang.org/x/sys v0.41.0 golang.org/x/text v0.35.0 + gorm.io/datatypes v1.2.7 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/PuerkitoBio/goquery v1.8.0 // indirect github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect github.com/ajstarks/svgo v0.0.0-20200320125537-f189e35d30ca // indirect @@ -66,15 +67,18 @@ require ( github.com/fumiama/go-simple-protobuf v0.2.0 // indirect github.com/fumiama/gofastTEA v0.1.3 // indirect github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a // indirect + github.com/fumiama/imgsz v0.0.4 // indirect github.com/fumiama/orbyte v0.0.0-20251002065953-3bb358367eb5 // indirect github.com/fumiama/terasu v1.0.2 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/gopxl/beep/v2 v2.1.1 // indirect github.com/jfreymuth/oggvorbis v1.0.5 // indirect github.com/jfreymuth/vorbis v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/liuzl/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect github.com/liuzl/da v0.0.0-20180704015230-14771aad5b1d // indirect @@ -97,6 +101,8 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/net v0.50.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/mysql v1.5.6 // indirect + gorm.io/gorm v1.30.0 // indirect modernc.org/libc v1.67.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 976028c4ae..7634d8e77f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Baidu-AIP/golang-sdk v1.1.1 h1:RQsAmgDSAkiq22I6n7XJ2t3afgzFeqjY46FGhvrx4cw= github.com/Baidu-AIP/golang-sdk v1.1.1/go.mod h1:bXnGw7xPeKt8aF7UCELKrV6UZ/46spItONK1RQBQj1Y= github.com/FloatTech/AnimeAPI v1.7.1-0.20260408142737-1e9f2594e3cd h1:1L91nA4W2FNCFlvxAT5UfIvmW40zeH3ho4KkHrlAt0c= @@ -100,10 +102,15 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -119,6 +126,14 @@ github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU= github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E= github.com/guohuiyuan/music-lib v1.0.2-0.20260121020416-53f6cb24629d h1:6Cw52c4JaYvq55yAa9ZgUQeBL6b3ZWErQqkbeMZiAYw= github.com/guohuiyuan/music-lib v1.0.2-0.20260121020416-53f6cb24629d/go.mod h1:D/6kQDwhQFDNZEMjN8y760DQSVYpOGlQXrYzhKz0rCQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= @@ -127,8 +142,9 @@ github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jozsefsallai/gophersauce v1.0.1 h1:BA3ovtQRrAb1qYU9JoRLbDHpxnDunlNcEkEfhCvDDCM= github.com/jozsefsallai/gophersauce v1.0.1/go.mod h1:YVEI7djliMTmZ1Vh01YPF8bUHi+oKhe3yXgKf1T49vg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -149,8 +165,11 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= @@ -316,6 +335,19 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= +gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= +gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs= diff --git a/main.go b/main.go index 4ee2794a68..d2660f0192 100644 --- a/main.go +++ b/main.go @@ -132,6 +132,7 @@ import ( _ "github.com/FloatTech/ZeroBot-Plugin/plugin/nwife" // 本地老婆 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/omikuji" // 浅草寺求签 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/pig" // 来份猪猪 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv" // pixiv _ "github.com/FloatTech/ZeroBot-Plugin/plugin/poker" // 抽扑克 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/qqwife" // 一群一天一夫一妻制群老婆 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/qzone" // qq空间表白墙 diff --git a/plugin/pixiv/api/client.go b/plugin/pixiv/api/client.go new file mode 100644 index 0000000000..c1e4f067c0 --- /dev/null +++ b/plugin/pixiv/api/client.go @@ -0,0 +1,149 @@ +package api + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/model" +) + +// HTTPStatusError 图片下载时的 HTTP 状态码 +type HTTPStatusError struct { + StatusCode int + URL string +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("下载图片失败: HTTP %d", e.StatusCode) +} + +// Client 封装 HTTP 客户端与 Pixiv 请求逻辑 +type Client struct { + *http.Client + transport *http.Transport +} + +func NewClient() *Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{MaxVersion: tls.VersionTLS13}, + Proxy: http.ProxyFromEnvironment, + } + return &Client{ + Client: &http.Client{ + Transport: transport, + Timeout: time.Minute, + }, + transport: transport, + } +} + +// SetProxy 设置代理 +func (c *Client) SetProxy(proxyURL string) error { + if c == nil || c.transport == nil { + return fmt.Errorf("pixiv client is nil") + } + proxyURL = strings.TrimSpace(proxyURL) + if proxyURL == "" { + c.transport.Proxy = http.ProxyFromEnvironment + return nil + } + u, err := url.Parse(proxyURL) + if err != nil { + return err + } + c.transport.Proxy = http.ProxyURL(u) + return nil +} + +func (c *Client) SearchPixivIllustrations(accessToken, url string) (*model.RootEntity, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + req.Header.Set("User-Agent", "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)") + req.Header.Set("App-OS", "android") + req.Header.Set("App-OS-Version", "11") + req.Header.Set("App-Version", "5.0.234") + + req.Header.Set("Accept-Language", "en_US") + req.Header.Set("Referer", "https://app-api.pixiv.net/") + req.Header.Set("Connection", "keep-alive") + + req.Host = "app-api.pixiv.net" + + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("搜索失败: %s\nbody: %s", resp.Status, string(body)) + } + + var result model.RootEntity + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (c *Client) fetchOnce(targetURL, referer string) ([]byte, int, error) { + req, err := http.NewRequest("GET", targetURL, nil) + if err != nil { + return nil, 0, err + } + + req.Header.Set("Referer", referer) + req.Header.Set("User-Agent", "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)") + + resp, err := c.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + return data, resp.StatusCode, nil +} + +// FetchPixivImage 直接从 Pixiv 下载图片 +func (c *Client) FetchPixivImage(illust model.IllustCache, url string) ([]byte, error) { + fmt.Println("下载", illust.PID) + + if c == nil { + fmt.Println("FetchPixivImage called on nil IllustCache") + return nil, nil + } + + data, status, err := c.fetchOnce(url, "https://www.pixiv.net/") + if err == nil && status == http.StatusOK { + return data, nil + } + if status == http.StatusNotFound { + return nil, &HTTPStatusError{StatusCode: status, URL: url} + } + + if err != nil { + return nil, err + } + + if status != 0 { + return nil, &HTTPStatusError{StatusCode: status, URL: url} + } + + return nil, fmt.Errorf("下载图片失败") +} diff --git a/plugin/pixiv/api/pixiv_api.go b/plugin/pixiv/api/pixiv_api.go new file mode 100644 index 0000000000..8fab7bbeb0 --- /dev/null +++ b/plugin/pixiv/api/pixiv_api.go @@ -0,0 +1,212 @@ +package api + +import ( + "fmt" + "sort" + + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/model" + log "github.com/sirupsen/logrus" +) + +type PixivAPI struct { + Client *Client + Token *TokenStore +} + +func NewPixivAPI(refreshToken string) *PixivAPI { + c := NewClient() + return &PixivAPI{ + Client: c, + Token: NewTokenStore(refreshToken, c), + } +} + +func (p *PixivAPI) FetchPixivByPID(pid int64) (*model.IllustCache, error) { + url := fmt.Sprintf("https://app-api.pixiv.net/v1/illust/detail?illust_id=%d", pid) + accessToken, err := p.Token.GetAccessToken() + if err != nil { + return nil, err + } + rawData, err := p.Client.SearchPixivIllustrations(accessToken, url) + if err != nil { + return nil, err + } + if rawData == nil || rawData.Illust == nil { + return nil, fmt.Errorf("pixiv 返回数据为空或结构不匹配") + } + return convertToIllustCache(rawData.Illust) +} + +func (p *PixivAPI) FetchPixivByUser(uid int64, limit int, pids []int64) ([]model.IllustCache, error) { + url := fmt.Sprintf("https://app-api.pixiv.net/v1/user/illusts?user_id=%d&type=illust", uid) + + excludeCache := make(map[int64]struct{}) + + for _, pid := range pids { + excludeCache[pid] = struct{}{} + } + return p.fetchPixivCommon(url, limit, nil, excludeCache) +} + +func (p *PixivAPI) FetchPixivRecommend(limit int) ([]model.IllustCache, error) { + firstURL := "https://app-api.pixiv.net/v1/illust/recommended?filter=for_ios" + return p.fetchPixivCommon(firstURL, limit, nil, nil) // 不做R18过滤,不排缓存 +} + +func (p *PixivAPI) FetchPixivIllusts(keyword string, isR18Req bool, limit int, cachedIds []int64) ([]model.IllustCache, error) { + cachedMap := make(map[int64]struct{}, len(cachedIds)) + if len(cachedIds) > 0 { + for _, id := range cachedIds { + cachedMap[id] = struct{}{} + } + } + + firstURL := buildPixivSearchURL(keyword) + return p.fetchPixivCommon(firstURL, limit, &isR18Req, cachedMap, keyword) +} + +func (p *PixivAPI) GetIllustsByKeyword(keyword string, limit int, cachedIllust []model.IllustCache, cached []int64) ([]model.IllustCache, error) { + + r18Req := IsR18(keyword) + keyword = RemoveR18Keywords(keyword) + + // 如果查到了,直接返回 + if len(cachedIllust) == limit { + return cachedIllust, nil + } + + // 设置一个保底的关键词 + if keyword == "" && r18Req { + keyword = "R-18" + } + + // 计算还需要几张图片 + needed := 0 + if len(cachedIllust) < limit { + needed = limit - len(cachedIllust) + } + + log.Printf("从数据库读到%d,还需要下载%d\n", len(cachedIllust), needed) + // 缓存没数据 -> 调用Pixiv API拉取 + pixivResults, err := p.FetchPixivIllusts(keyword, r18Req, needed, cached) + if err != nil && len(cachedIllust) == 0 { + return nil, err + } + + // 如果Pixiv也没查到直接返回空 + if len(pixivResults) == 0 && len(cachedIllust) == 0 { + return nil, fmt.Errorf("这个关键词可能没有找到符合条件的图片或出现未知错误") + } + + if len(cachedIllust) > 0 && len(pixivResults) == 0 { + log.Println("http没有找到图片") + return cachedIllust, nil + } + + pixivResults = append(pixivResults, cachedIllust...) + + if len(pixivResults) >= limit { + pixivResults = pixivResults[:limit] + } + + log.Println("预计发送", len(pixivResults), "张图片") + + return pixivResults, nil +} + +func (p *PixivAPI) fetchPixivCommon( + firstURL string, + limit int, + isR18Req *bool, + excludeCache map[int64]struct{}, + keywords ...string, +) ([]model.IllustCache, error) { + + accessToken, err := p.Token.GetAccessToken() + if err != nil { + return nil, err + } + + // 高质量图(≥1000) + high := make([]model.IllustCache, 0, limit) + // 低质量图(<1000) + low := make([]model.IllustCache, 0, limit) + + seen := make(map[int64]struct{}) + url := firstURL + + for url != "" { + rawData, err := p.Client.SearchPixivIllustrations(accessToken, url) + if err != nil { + return nil, err + } + + for i := range rawData.Illusts { + raw := &rawData.Illusts[i] + + // 去重 + if _, ok := seen[raw.Id]; ok { + continue + } + if excludeCache != nil { + if _, ok := excludeCache[raw.Id]; ok { + continue + } + } + seen[raw.Id] = struct{}{} + + tagNames := extractTagNames(raw.Tags) + + if isR18Req != nil && *isR18Req && !hasR18Tag(tagNames) { + continue + } + + // 转换 + ill, err := convertToIllustCache(raw) + if err != nil { + continue + } + if len(keywords) > 0 { + ill.Keyword = keywords[0] + } + + // R18过滤 + if isR18Req != nil && ill.R18 != *isR18Req { + continue + } + + // 判断高质量 + if ill.Bookmarks >= 1000 { + high = append(high, *ill) + // 高质量够了就直接返回 + if len(high) >= limit { + return high[:limit], nil + } + } else { + // 低质量池还没满 → 接受 + if len(low) <= limit { + low = append(low, *ill) + } + // 低质量够 limit 就不再放入,避免爆炸增长 + } + } + + // 下一页继续 + url = rawData.NextUrl + } + + // ==== 翻页结束:如果高质量不足,就从低质量中挑收藏最多的 ==== + + // 低质量排序(按收藏数倒序) + sort.Slice(low, func(i, j int) bool { + return low[i].Bookmarks > low[j].Bookmarks + }) + + // 用低质量中的高质量去补齐不足的图 + all := append(high, low...) + if len(all) > limit { + all = all[:limit] + } + + return all, nil +} diff --git a/plugin/pixiv/api/token.go b/plugin/pixiv/api/token.go new file mode 100644 index 0000000000..0ad32f966f --- /dev/null +++ b/plugin/pixiv/api/token.go @@ -0,0 +1,83 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +type TokenStore struct { + client *Client + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + ExpiresAt time.Time `json:"-"` + mu sync.Mutex +} + +func NewTokenStore(refreshToken string, c *Client) *TokenStore { + return &TokenStore{ + RefreshToken: refreshToken, + client: c, + } +} + +func (t *TokenStore) GetAccessToken() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if time.Now().Before(t.ExpiresAt) && t.AccessToken != "" { + log.Println("access_token is valid") + return t.AccessToken, nil + } + + if err := t.refreshPixivAccessToken(); err != nil { + return "", err + } + + t.ExpiresAt = time.Now().Add(time.Duration(t.ExpiresIn/2) * time.Second) + return t.AccessToken, nil +} + +// refreshPixivAccessToken 用 refresh_token 刷新 access_token +func (t *TokenStore) refreshPixivAccessToken() error { + endpoint := "https://oauth.secure.pixiv.net/auth/token" + + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("client_id", "MOBrBDS8blbauoSck0ZfDbtuzpyT") + data.Set("client_secret", "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj") + data.Set("refresh_token", t.RefreshToken) + + req, _ := http.NewRequest("POST", endpoint, bytes.NewBufferString(data.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)") + + resp, err := t.client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("刷新失败: %s\nbody: %s", resp.Status, string(body)) + } + + var tokenRes TokenStore + err = json.Unmarshal(body, &tokenRes) + + t.AccessToken = tokenRes.AccessToken + t.ExpiresIn = tokenRes.ExpiresIn + + return err +} diff --git a/plugin/pixiv/api/utils.go b/plugin/pixiv/api/utils.go new file mode 100644 index 0000000000..fc150c3c15 --- /dev/null +++ b/plugin/pixiv/api/utils.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/model" +) + +func RemoveR18Keywords(keyword string) string { + if keyword == "" { + return keyword + } + + words := strings.Fields(keyword) // 按空格分割成单词 + var result []string + + for _, word := range words { + lowerWord := strings.ToLower(word) + // 只删除完全匹配的R-18 关键词 + if lowerWord != "r-18" && lowerWord != "r18" && lowerWord != "r_18" { + result = append(result, word) + } + } + + return strings.Join(result, " ") +} + +func buildPixivSearchURL(keyword string) string { + + baseURL := &url.URL{ + Scheme: "https", + Host: "app-api.pixiv.net", + Path: "/v1/search/illust", + } + + params := url.Values{} + params.Set("word", keyword) + // 严格匹配 exact_match_for_tags + // 标题简介有相同的 title_and_caption + // 宽松匹配 partial_match_for_tags + // 暂时使用宽松匹配 + params.Set("search_target", "partial_match_for_tags") + params.Set("sort", "popular_desc") + //params.Set("offset", fmt.Sprintf("%d", offset)) + params.Set("order", "date_desc") + params.Set("filter", "for_android") + // params.Set("filter", "for_ios") + // params.Set("bookmark_num_min", "1000") + + baseURL.RawQuery = params.Encode() + return baseURL.String() +} + +func hasR18Tag(tags []string) bool { + for _, tag := range tags { + if IsR18(tag) { + return true + } + } + return false +} + +func extractTagNames(tags []model.TagsEntity) []string { + tagNames := make([]string, 0, len(tags)) + for _, tag := range tags { + tagNames = append(tagNames, tag.Name) + } + return tagNames +} + +func IsR18(s string) bool { + if s == "" { + return false + } + lower := strings.ToLower(s) + r18Keywords := []string{"r-18", "r18", "r_18"} + for _, keyword := range r18Keywords { + if strings.Contains(lower, keyword) { + return true + } + } + return false +} + +func ModifyPageGeneric(originalURL string, pageNum int) string { + u, err := url.Parse(originalURL) + if err != nil { + return originalURL + } + + // 获取路径的最后一部分(文件名) + pathParts := strings.Split(u.Path, "/") + if len(pathParts) == 0 { + return originalURL + } + + fileName := pathParts[len(pathParts)-1] + parts := strings.Split(fileName, "_") + if len(parts) < 2 { + return originalURL + } + + // 分离页码和扩展名 + pageAndExt := strings.Split(parts[1], ".") + if len(pageAndExt) < 2 { + return originalURL + } + + // 修改页码部分,保留扩展名 + parts[1] = fmt.Sprintf("p%d.%s", pageNum, pageAndExt[1]) + + // 更新文件名 + pathParts[len(pathParts)-1] = strings.Join(parts, "_") + u.Path = strings.Join(pathParts, "/") + + return u.String() +} + +func convertToIllustCache(raw *model.IllustsEntity) (*model.IllustCache, error) { + tagNames := extractTagNames(raw.Tags) + + jsonTags, err := json.Marshal(tagNames) + if err != nil { + return nil, err + } + + if len(tagNames) == 0 { + tagNames = []string{""} + } + + illust := &model.IllustCache{ + PID: raw.Id, + UID: raw.User.Id, + Keyword: tagNames[0], // 默认为第1个标签后续在其他函数里自定义 + Title: raw.Title, + AuthorName: raw.User.Name, + ImageURL: raw.ImageUrls.Large, + R18: hasR18Tag(tagNames), + Bookmarks: raw.TotalBookmarks, + TotalView: raw.TotalView, + CreateDate: raw.CreateDate, + PageCount: raw.PageCount, + Tags: jsonTags, + } + + originalImageURL := raw.MetaSinglePage.OriginalImageUrl + if originalImageURL == "" && len(raw.MetaPages) > 0 { + originalImageURL = raw.MetaPages[0].ImageURLs.Original + } + + illust.OriginalURL = originalImageURL + + return illust, nil +} diff --git a/plugin/pixiv/cache/db.go b/plugin/pixiv/cache/db.go new file mode 100644 index 0000000000..d1fbc0e34a --- /dev/null +++ b/plugin/pixiv/cache/db.go @@ -0,0 +1,212 @@ +package cache + +import ( + "errors" + "strings" + "sync" + "time" + + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/model" + "github.com/jinzhu/gorm" +) + +type DB struct { + *gorm.DB + mu sync.Mutex +} + +func NewDB(path string) *DB { + db, err := gorm.Open("sqlite3", path) + if err != nil { + panic(err) + } + if err = db.AutoMigrate(&model.IllustCache{}, &model.SentImage{}, &model.RefreshToken{}, &model.GroupR18Permission{}, &model.PixivProxyConfig{}).Error; err != nil { + panic(err) + } + sqlDB := db.DB() + sqlDB.SetMaxOpenConns(10) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(time.Hour) + db.LogMode(false) + return &DB{db, sync.Mutex{}} +} + +func (db *DB) CheckGroupR18Permission(gid int64) bool { + db.mu.Lock() + defer db.mu.Unlock() + + var count int64 + db.Model(&model.GroupR18Permission{}).Where("group_id = ?", gid).Count(&count) + return count > 0 +} + +func (db *DB) findByKeyword(gid int64, keyword string, limit int, r18Req bool) ([]model.IllustCache, error) { + db.mu.Lock() + defer db.mu.Unlock() + + var results []model.IllustCache + query := db.Model(&model.IllustCache{}). + Where("keyword = ?", keyword). + Where("pid NOT IN (?)", db.Model(&model.SentImage{}).Where("group_id = ?", gid).Select("pid").SubQuery()). + Order("bookmarks DESC"). + Limit(limit) + + query = query.Where("r18 = ?", r18Req) + + err := query.Find(&results).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return results, nil +} + +func (db *DB) findByTag(gid int64, tag string, needed int, r18Req bool) ([]model.IllustCache, error) { + if needed <= 0 { + return nil, nil + } + db.mu.Lock() + defer db.mu.Unlock() + + var results []model.IllustCache + escapedTag := strings.ReplaceAll(tag, "\"", "\\\"") + likePattern := "%\"" + escapedTag + "\"%" + + query := db.Model(&model.IllustCache{}). + Where("tags LIKE ?", likePattern). + Where("pid NOT IN (?)", db.Model(&model.SentImage{}).Where("group_id = ?", gid).Select("pid").SubQuery()). + Order("bookmarks DESC"). + Limit(needed) + + if !r18Req { + query = query.Where("r18 = ?", false) + } + + err := query.Find(&results).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + return results, nil +} +func (db *DB) CountIllustsSmart(gid int64, keyword string, r18Req bool) (int64, error) { + db.mu.Lock() + defer db.mu.Unlock() + + var count int64 + + query := db.Model(&model.IllustCache{}). + Where("keyword = ?", keyword). + Where("pid NOT IN (?)", db.Model(&model.SentImage{}).Where("group_id = ?", gid).Select("pid").SubQuery()) + + if !r18Req { + query = query.Where("r18 = ?", false) + } + err := query.Count(&count).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + return count, nil +} + +func (db *DB) FindIllustsSmart(gid int64, keyword string, limit int, r18Req bool) ([]model.IllustCache, error) { + seen := make(map[int64]struct{}) + var results []model.IllustCache + + // 1. keyword 严格查询 + kwRes, err := db.findByKeyword(gid, keyword, limit, r18Req) + if err != nil { + return nil, err + } + for _, ill := range kwRes { + results = append(results, ill) + seen[ill.PID] = struct{}{} + } + + if len(results) >= limit { + return results[:limit], nil + } + + // 2. tag 查询补齐 + need := limit - len(results) + for _, tagKeyword := range strings.Fields(keyword) { + tagRes, err := db.findByTag(gid, tagKeyword, need, r18Req) + if err != nil { + return nil, err + } + + for _, ill := range tagRes { + if _, ok := seen[ill.PID]; ok { + continue + } + results = append(results, ill) + seen[ill.PID] = struct{}{} + if len(results) >= limit { + break + } + } + + if len(results) >= limit { + break + } + + need = limit - len(results) + } + + return results, nil +} + +func (db *DB) GetIllustIDsByKeyword(keyword string) ([]int64, error) { + db.mu.Lock() + defer db.mu.Unlock() + + var illustIDs []int64 + err := db.Model(&model.IllustCache{}).Where("keyword = ?", keyword).Pluck("pid", &illustIDs).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return illustIDs, nil +} + +func (db *DB) GetSentPictureIDs(gid int64) ([]int64, error) { + db.mu.Lock() + defer db.mu.Unlock() + + var pictureIDs []int64 + err := db.Model(&model.SentImage{}).Where("group_id = ?", gid).Pluck("pid", &pictureIDs).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return pictureIDs, nil +} + +// DeleteIllustByPID 删除指定 PID 的插画缓存记录 +func (db *DB) DeleteIllustByPID(pid int64) error { + db.mu.Lock() + defer db.mu.Unlock() + + return db.Where("pid = ?", pid).Delete(&model.IllustCache{}).Error +} + +// Create wraps gorm.DB.Create with a mutex to avoid SQLITE_BUSY when requests are concurrent. +func (db *DB) Create(value interface{}) *gorm.DB { + db.mu.Lock() + defer db.mu.Unlock() + + return db.DB.Create(value) +} + +// Save wraps gorm.DB.Save with a mutex to avoid SQLITE_BUSY when requests are concurrent. +func (db *DB) Save(value interface{}) *gorm.DB { + db.mu.Lock() + defer db.mu.Unlock() + + return db.DB.Save(value) +} + +// Delete wraps gorm.DB.Delete with a mutex to avoid SQLITE_BUSY when requests are concurrent. +func (db *DB) Delete(value interface{}, where ...interface{}) *gorm.DB { + db.mu.Lock() + defer db.mu.Unlock() + + return db.DB.Delete(value, where...) +} diff --git a/plugin/pixiv/main.go b/plugin/pixiv/main.go new file mode 100644 index 0000000000..cedfa07a89 --- /dev/null +++ b/plugin/pixiv/main.go @@ -0,0 +1,298 @@ +package pixiv + +import ( + "math/rand" + "os" + "strconv" + + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/api" + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/cache" + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/model" + "github.com/FloatTech/floatbox/file" + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + log "github.com/sirupsen/logrus" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" +) + +var defaultKeyword = []string{"萝莉", "御姐", "妹妹", "姐姐", "车万"} + +var ( + service *Service +) + +func init() { + if file.IsNotExist("data/pixiv") { + err := os.MkdirAll("data/pixiv", 0775) + if err != nil { + panic(err) + } + } + if file.IsNotExist(pixivTempDir) { + err := os.MkdirAll(pixivTempDir, 0775) + if err != nil { + panic(err) + } + } + + db := cache.NewDB("data/pixiv/pixiv.db") + + var t1 model.RefreshToken + if err := db.First(&t1).Error; err != nil { + log.Warning("Fail fetching token store from database") + } + + pixivAPI := api.NewPixivAPI(t1.Token) + + var proxyCfg model.PixivProxyConfig + proxyCfg.Name = "global" + if err := db.Where("name = ?", proxyCfg.Name).FirstOrCreate(&proxyCfg).Error; err == nil { + if err := pixivAPI.Client.SetProxy(proxyCfg.Proxy); err != nil { + log.Warning("设置pixiv代理错误: ", err) + } + } + + service = NewService(db, pixivAPI) +} + +const help = ` +- [x张]涩图 [关键词] +- 每日涩图 +- [x张]画师[画师的uid] +- p站搜图[插画pid] +- 设置p站token [token] +- 授权此处使用p站18 +- 设置pixiv代理 [http://127.0.0.1:7890] +- 查看pixiv代理 +- 清除pixiv代理 +[]不用打出来这只是一个占位符 +可添加多个关键词每个关键词用空格隔开 +默认不发R-18如果要发就加一个R-18关键词 +` + +func init() { + engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Brief: "Pixiv 图片搜索", + Help: help, + }) + + engine.OnFullMatch("授权此处使用p站18", zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + id := ctx.Event.GroupID + if id == 0 { + id = -ctx.Event.UserID + } + if err := service.DB.Create(&model.GroupR18Permission{GroupID: id}).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("已允许")) + }) + + engine.OnRegex(`^设置p站token\s+(.*)`, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + token := ctx.State["regex_matched"].([]string)[1] + var refreshToken model.RefreshToken + refreshToken.User = ctx.Event.UserID + refreshToken.Token = token + if err := service.DB.Save(&refreshToken).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + service.API.Token.RefreshToken = token + + ctx.SendChain(message.Text("Pixiv Token: ", token)) + }) + + engine.OnRegex(`^设置pixiv代理\s+([\s\S]+)$`, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + proxy := ctx.State["regex_matched"].([]string)[1] + + var proxyCfg model.PixivProxyConfig + proxyCfg.Name = "global" + if err := service.DB.Where("name = ?", proxyCfg.Name).FirstOrCreate(&proxyCfg).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + proxyCfg.Proxy = proxy + if err := service.DB.Save(&proxyCfg).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + if err := service.API.Client.SetProxy(proxy); err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + + ctx.SendChain(message.Text("Pixiv client proxy 已设置为: ", proxy)) + }) + + engine.OnFullMatch("清除pixiv代理", zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + var proxyCfg model.PixivProxyConfig + proxyCfg.Name = "global" + if err := service.DB.Where("name = ?", proxyCfg.Name).FirstOrCreate(&proxyCfg).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + proxyCfg.Proxy = "" + if err := service.DB.Save(&proxyCfg).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + if err := service.API.Client.SetProxy(""); err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + ctx.SendChain(message.Text("Pixiv client proxy 已清除")) + }) + + engine.OnFullMatch("查看pixiv代理", zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) { + var proxyCfg model.PixivProxyConfig + proxyCfg.Name = "global" + if err := service.DB.Where("name = ?", proxyCfg.Name).FirstOrCreate(&proxyCfg).Error; err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + if proxyCfg.Proxy == "" { + ctx.SendChain(message.Text("Pixiv client proxy: 未设置")) + return + } + ctx.SendChain(message.Text("Pixiv client proxy: ", proxyCfg.Proxy)) + }) + + engine.OnRegex(`^p站搜图(\d+)`).SetBlock(true).Handle(func(ctx *zero.Ctx) { + rawPID := ctx.State["regex_matched"].([]string)[1] + pid, err := strconv.ParseInt(rawPID, 10, 64) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + illust, err := service.API.FetchPixivByPID(pid) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + _ = service.DB.Create(illust) + service.SendIllusts(ctx, []model.IllustCache{*illust}) + }) + + engine.OnRegex(`^(\d+)?张?画师(\d+)`).SetBlock(true).Handle(func(ctx *zero.Ctx) { + if !service.Acquire(ctx.Event.UserID) { + ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text("上一个任务还没结束,请稍后再试")) + return + } + defer service.Release(ctx.Event.UserID) + + limit := ctx.State["regex_matched"].([]string)[1] + if limit == "" { + limit = "1" + } + rawUid := ctx.State["regex_matched"].([]string)[2] + limitInt, err := strconv.Atoi(limit) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + uid, err := strconv.ParseInt(rawUid, 10, 64) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + pictureIDs, err := service.DB.GetSentPictureIDs(ctx.Event.GroupID) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + illustInfos, err := service.API.FetchPixivByUser(uid, limitInt, pictureIDs) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + if len(illustInfos) == 0 { + ctx.SendChain(message.Text("没有找到图片")) + return + } + + service.SendIllusts(ctx, illustInfos) + }) + + engine.OnRegex(`^每日[色|涩|瑟]图$`).SetBlock(true).Handle(func(ctx *zero.Ctx) { + illusts, err := service.API.FetchPixivRecommend(1) + if err != nil { + ctx.SendChain(message.Text("发送涩图失败惹")) + return + } + service.SendIllusts(ctx, []model.IllustCache{illusts[0]}) + }) + + engine.OnRegex(`^(\d+)?张?[色|瑟|涩]图\s*(.+)?`).SetBlock(true).Handle(func(ctx *zero.Ctx) { + limit := ctx.State["regex_matched"].([]string)[1] + keyword := ctx.State["regex_matched"].([]string)[2] + + if !service.Acquire(ctx.Event.UserID) { + ctx.SendChain(message.Reply(ctx.Event.MessageID), message.Text("上一个任务还没结束,请稍后再试")) + return + } + defer service.Release(ctx.Event.UserID) + + if limit == "" { + limit = "1" + } + + if keyword == "" { + keyword = defaultKeyword[rand.Intn(len(defaultKeyword))] + } + + limitInt, err := strconv.Atoi(limit) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + + if limitInt > 10 { + ctx.SendChain(message.Text("图片太多了")) + return + } + + gid := ctx.Event.GroupID + r18Req := api.IsR18(keyword) + cleanKeyword := api.RemoveR18Keywords(keyword) + + if gid == 0 { + gid = -ctx.Event.UserID + } + + if r18Req && !service.DB.CheckGroupR18Permission(gid) { + ctx.SendChain(message.Text([]string{ + "笨蛋笨蛋大笨蛋", + "此处未授权r18", + }[rand.Intn(2)])) + return + } + + cachedIllusts, err := service.DB.FindIllustsSmart(gid, cleanKeyword, limitInt, r18Req) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + + cached, _ := service.DB.GetSentPictureIDs(gid) + + for _, ill := range cachedIllusts { + cached = append(cached, ill.PID) + } + + illusts, err := service.API.GetIllustsByKeyword(keyword, limitInt, cachedIllusts, cached) + if err != nil { + ctx.SendChain(message.Text("ERROR: ", err)) + return + } + + for _, illust := range illusts { + log.Print("获取到图片 PID: ", illust.PID) + _ = service.DB.Create(&illust).Error + } + + service.SendIllusts(ctx, illusts) + service.BackgroundCacheFiller(cleanKeyword, 10, r18Req, 5, ctx.Event.GroupID) + }) +} diff --git a/plugin/pixiv/model/model.go b/plugin/pixiv/model/model.go new file mode 100644 index 0000000000..5f8417060d --- /dev/null +++ b/plugin/pixiv/model/model.go @@ -0,0 +1,126 @@ +package model + +import ( + "github.com/jinzhu/gorm" + "gorm.io/datatypes" +) + +// IllustCache 插画缓存表 +type IllustCache struct { + gorm.Model + + PID int64 `gorm:"unique_index:idx_keyword_pid;not null;column:pid"` // Pixiv 作品 ID + UID int64 `gorm:"default:0;not null;column:uid"` // 插画作者的id + Keyword string `gorm:"unique_index:idx_keyword_pid;type:varchar(255)"` // 搜索关键词 + Title string `gorm:"type:varchar(255)"` // 标题 + AuthorName string `gorm:"type:varchar(255)"` // 用户名 + ImageURL string `gorm:"type:varchar(500)"` // 大图地址 + OriginalURL string `gorm:"type:varchar(500)"` // 原图地址 + R18 bool `gorm:"not null;default:false"` // 是否为 R-18 作品 + Bookmarks int64 // 收藏数 + TotalView int64 // 总浏览数 + CreateDate string // 创建日期 + PageCount int64 `gorm:"default:1"` // 页数 + Tags datatypes.JSON `gorm:"type:json"` // 插画的所有标签 方便后续查找 +} + +// SentImage 已发送记录表 +type SentImage struct { + gorm.Model + + GroupID int64 `gorm:"index:idx_group_pid;not null"` // 群组 ID + PID int64 `gorm:"index:idx_group_pid;not null;column:pid"` // 插画 PID +} + +// GroupR18Permission 群组R18权限表 +type GroupR18Permission struct { + gorm.Model + GroupID int64 `gorm:"unique_index"` +} + +type RefreshToken struct { + gorm.Model + + User int64 `gorm:"unique"` + + Token string +} + +// PixivProxyConfig 保存 Pixiv API 客户端的代理配置 +type PixivProxyConfig struct { + gorm.Model + + Name string `gorm:"unique_index"` + Proxy string +} + +type RootEntity struct { + Illusts []IllustsEntity `json:"illusts"` + Illust *IllustsEntity `json:"illust"` + NextUrl string `json:"next_url"` + SearchSpanLimit int64 `json:"search_span_limit"` + ShowAi bool `json:"show_ai"` +} + +type IllustsEntity struct { + Id int64 `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + ImageUrls ImageUrlsEntity `json:"image_urls"` + Caption string `json:"caption"` + Restrict int64 `json:"restrict"` + User UserEntity `json:"user"` + Tags []TagsEntity `json:"tags"` + CreateDate string `json:"create_date"` + PageCount int64 `json:"page_count"` + Width int64 `json:"width"` + Height int64 `json:"height"` + SanityLevel int64 `json:"sanity_level"` + XRestrict int64 `json:"x_restrict"` + MetaSinglePage MetaSinglePageEntity `json:"meta_single_page"` + MetaPages []MetaPage `json:"meta_pages"` + TotalView int64 `json:"total_view"` + TotalBookmarks int64 `json:"total_bookmarks"` + IsBookmarked bool `json:"is_bookmarked"` + Visible bool `json:"visible"` + IsMuted bool `json:"is_muted"` + IllustAiType int64 `json:"illust_ai_type"` + IllustBookStyle int64 `json:"illust_book_style"` +} + +type MetaPage struct { + ImageURLs struct { + SquareMedium string `json:"square_medium"` + Medium string `json:"medium"` + Large string `json:"large"` + Original string `json:"original"` + } `json:"image_urls"` +} + +type ImageUrlsEntity struct { + SquareMedium string `json:"square_medium"` + Medium string `json:"medium"` + Large string `json:"large"` +} + +type UserEntity struct { + Id int64 `json:"id"` + Name string `json:"name"` + Account string `json:"account"` + ProfileImageUrls ProfileImageUrlsEntity `json:"profile_image_urls"` + IsFollowed bool `json:"is_followed"` + IsAcceptRequest bool `json:"is_accept_request"` +} + +type ProfileImageUrlsEntity struct { + Medium string `json:"medium"` +} + +type TagsEntity struct { + Name string `json:"name"` + TranslatedName interface{} `json:"translated_name"` +} + +type MetaSinglePageEntity struct { + OriginalImageUrl string `json:"original_image_url"` +} diff --git a/plugin/pixiv/service.go b/plugin/pixiv/service.go new file mode 100644 index 0000000000..8c4a889bb4 --- /dev/null +++ b/plugin/pixiv/service.go @@ -0,0 +1,373 @@ +package pixiv + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/api" + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/cache" + "github.com/FloatTech/ZeroBot-Plugin/plugin/pixiv/model" + "github.com/FloatTech/floatbox/file" + log "github.com/sirupsen/logrus" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" +) + +var cacheFilling sync.Map + +// Service 用于封装整个 Pixiv 模块的依赖与接口 +type Service struct { + DB *cache.DB + API *api.PixivAPI + // 内部任务锁:限制每个人同一时间只能执行一个请求 + taskMu sync.Mutex + tasks map[int64]*taskState + + // 并发控制 + DownloadWorkers int + SendWorkers int +} + +type taskState struct { + Running bool +} + +const pixivTempDir = "data/pixiv/temp" + +func NewService(db *cache.DB, api *api.PixivAPI) *Service { + return &Service{ + DB: db, + API: api, + tasks: make(map[int64]*taskState), + DownloadWorkers: 4, + SendWorkers: 2, + } +} + +func (s *Service) Acquire(userID int64) bool { + s.taskMu.Lock() + defer s.taskMu.Unlock() + + t, ok := s.tasks[userID] + if ok && t.Running { + return false + } + + if !ok { + t = &taskState{} + s.tasks[userID] = t + } + t.Running = true + return true +} + +func (s *Service) Release(userID int64) { + s.taskMu.Lock() + defer s.taskMu.Unlock() + + if t, ok := s.tasks[userID]; ok { + t.Running = false + } + delete(s.tasks, userID) +} + +func removeTempImages(paths []string) { + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + log.Warnf("删除 pixiv 临时文件失败: %s: %v", p, err) + } + } +} + +func scheduleCleanupPixivImages(paths []string, delay time.Duration) { + if len(paths) == 0 { + return + } + local := append([]string(nil), paths...) + go func() { + time.Sleep(delay) + removeTempImages(local) + }() +} + +func pixivImageExt(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return ".jpg" + } + ext := path.Ext(parsed.Path) + if ext == "" { + return ".jpg" + } + return ext +} + +func createPixivTempImagePath(pid int64, index int, rawURL string) (string, error) { + baseDir := filepath.Join(file.BOTPATH, pixivTempDir) + if err := os.MkdirAll(baseDir, 0o775); err != nil { + return "", err + } + pattern := fmt.Sprintf("%d-", pid) + if index > 0 { + pattern = fmt.Sprintf("%d-%d-", pid, index) + } + tmp, err := os.CreateTemp(baseDir, pattern+"*"+pixivImageExt(rawURL)) + if err != nil { + return "", err + } + name := tmp.Name() + if err = tmp.Close(); err != nil { + return "", err + } + return name, nil +} + +func writeTempImages(pid int64, images [][]byte, urls []string) ([]string, error) { + paths := make([]string, 0, len(images)) + cleanup := func() { + removeTempImages(paths) + } + + for i, img := range images { + if len(img) == 0 { + continue + } + rawURL := "" + if i < len(urls) { + rawURL = urls[i] + } + imagePath, err := createPixivTempImagePath(pid, i+1, rawURL) + if err != nil { + cleanup() + return nil, err + } + if err = os.WriteFile(imagePath, img, 0o644); err != nil { + cleanup() + return nil, err + } + paths = append(paths, imagePath) + } + + return paths, nil +} + +func toFileImage(path string) message.Segment { + normalized := strings.ReplaceAll(path, "\\", "/") + if strings.HasPrefix(normalized, "/") { + return message.Image("file://" + normalized) + } + return message.Image("file:///" + normalized) +} + +func (s *Service) SendIllusts(ctx *zero.Ctx, illusts []model.IllustCache) { + downloadSem := make(chan struct{}, s.DownloadWorkers) + type DLResult struct { + Ill model.IllustCache + Images [][]byte + Err error + } + + gid := ctx.Event.GroupID + if gid == 0 { + gid = -ctx.Event.UserID + } + + results := make(chan DLResult, len(illusts)) + + // 并发下载 + for _, ill := range illusts { + ill1 := ill + downloadSem <- struct{}{} + + go func() { + defer func() { <-downloadSem }() + + d := DLResult{ + Ill: ill1, + } + + if ill1.PageCount > 1 { + + type pageResult struct { + index int + data []byte + err error + } + + pageCh := make(chan pageResult, ill1.PageCount) + var wg sync.WaitGroup + + pageSem := make(chan struct{}, s.DownloadWorkers) + + for i := 0; int64(i) < ill1.PageCount; i++ { + wg.Add(1) + pageSem <- struct{}{} + + go func(page int) { + defer wg.Done() + defer func() { <-pageSem }() + + u := api.ModifyPageGeneric(ill1.OriginalURL, page) + img, err := s.API.Client.FetchPixivImage(ill1, u) + if err != nil { + pageCh <- pageResult{ + index: page, + err: err, + } + return + } + pageCh <- pageResult{ + index: page, + data: img, + err: err, + } + }(i) + } + + // 关闭 channel + go func() { + wg.Wait() + close(pageCh) + }() + + // 预分配,保证顺序 + d.Images = make([][]byte, ill1.PageCount) + + for r := range pageCh { + if r.err != nil && d.Err == nil { + d.Err = r.err + } + if len(r.data) == 0 { + continue + } + d.Images[r.index] = r.data + } + + } else { + img, err := s.API.Client.FetchPixivImage(ill1, ill1.OriginalURL) + if err == nil { + d.Images = append(d.Images, img) + } else { + d.Err = err + } + } + + log.Print("下载图片完成:", ill1.PID) + results <- d + }() + + } + + // 发送(顺序) + for range illusts { + res := <-results + + if res.Err != nil { + if httpErr, ok := errors.AsType[*api.HTTPStatusError](res.Err); ok && httpErr.StatusCode == http.StatusNotFound { + if err := s.DB.DeleteIllustByPID(res.Ill.PID); err != nil { + ctx.SendChain(message.Text("清理已失效图片失败: ", err)) + } else { + ctx.SendChain(message.Text("图片已被删除,已移除缓存,PID: ", res.Ill.PID)) + } + } else { + ctx.SendChain(message.Text("下载失败: ", res.Err)) + } + + continue + } + + var msg message.Message + msg = append(msg, message.Text( + "PID:", res.Ill.PID, + "\n标题:", res.Ill.Title, + "\n画师:", res.Ill.AuthorName, + "\ntag:", res.Ill.Tags, + "\n收藏数:", res.Ill.Bookmarks, + "\n浏览数:", res.Ill.TotalView, + "\n发布时间:", res.Ill.CreateDate, + )) + + imageURLs := make([]string, len(res.Images)) + for i := range imageURLs { + if i == 0 { + imageURLs[i] = res.Ill.OriginalURL + continue + } + imageURLs[i] = api.ModifyPageGeneric(res.Ill.OriginalURL, i) + } + + tempPaths, err := writeTempImages(res.Ill.PID, res.Images, imageURLs) + if err != nil { + ctx.SendChain(message.Text("暂存图片失败: ", err)) + continue + } + for _, tpath := range tempPaths { + msg = append(msg, toFileImage(tpath)) + } + + ctx.Send(msg) + scheduleCleanupPixivImages(tempPaths, 15*time.Second) + + s.DB.Create(&model.SentImage{ + GroupID: gid, + PID: res.Ill.PID, + }) + } +} + +func (s *Service) BackgroundCacheFiller(keyword string, minCache int, r18Req bool, fetchCount int, gid int64) { + if _, loaded := cacheFilling.LoadOrStore(keyword, struct{}{}); loaded { + log.Print("已有后台任务在补缓存: ", keyword) + return + } + + go func() { + defer cacheFilling.Delete(keyword) + + count, err := s.DB.CountIllustsSmart(gid, keyword, r18Req) + if err != nil { + log.Print("查询数据库发生错误: ", err) + return + } + + if count >= int64(minCache) { + log.Print("缓存足够,无需补充: ", keyword) + return + } + + log.Printf("后台补充关键词 %s, 数量 %d\n", keyword, fetchCount) + + sendedcache, err := s.DB.GetSentPictureIDs(gid) + if err != nil { + log.Print("后台补充缓存失败: ", err) + return + } + s1, err := s.DB.GetIllustIDsByKeyword(keyword) + sendedcache = append(sendedcache, s1...) + newIllusts, err := s.API.FetchPixivIllusts(keyword, r18Req, fetchCount, sendedcache) + if err != nil { + log.Print("后台补充缓存失败: ", err) + return + } + + if len(newIllusts) == 0 { + log.Print("后台补充缓存:没有新图") + return + } + + for _, illust := range newIllusts { + s.DB.Create(&illust) + log.Print("后台补充缓存:", illust.PID) + } + + log.Printf("后台成功补充 %d 张图片到关键词 %s 缓存\n", len(newIllusts), keyword) + }() +}