├── .github
└── workflows
│ └── pypi-publish.yml
├── .gitignore
├── LICENSE
├── README.md
├── nonebot_plugin_twitter
├── __init__.py
├── api.py
└── config.py
└── pyproject.toml
/.github/workflows/pypi-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | pypi-publish:
11 | name: Upload release to PyPI
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@master
15 | - name: Set up Python
16 | uses: actions/setup-python@v1
17 | with:
18 | python-version: "3.x"
19 | - name: Install pypa/build
20 | run: >-
21 | python -m
22 | pip install
23 | build
24 | --user
25 | - name: Build a binary wheel and a source tarball
26 | run: >-
27 | python -m
28 | build
29 | --sdist
30 | --wheel
31 | --outdir dist/
32 | .
33 | - name: Publish distribution to PyPI
34 | uses: pypa/gh-action-pypi-publish@release/v1
35 | with:
36 | password: ${{ secrets.PYPI_API_TOKEN }}
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 |
8 | #my
9 | nonebot_plugin_twitter/__new__.py
10 |
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | wheels/
27 | share/python-wheels/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 | MANIFEST
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 | cover/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | .pybuilder/
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | # For a library or package, you might want to ignore these files since the code is
91 | # intended to run in multiple environments; otherwise, check them in:
92 | # .python-version
93 |
94 | # pipenv
95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
98 | # install all needed dependencies.
99 | #Pipfile.lock
100 |
101 | # poetry
102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
103 | # This is especially recommended for binary packages to ensure reproducibility, and is more
104 | # commonly ignored for libraries.
105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
106 | #poetry.lock
107 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
108 | poetry.toml
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/#use-with-ide
116 | .pdm-python
117 |
118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
119 | __pypackages__/
120 |
121 | # Celery stuff
122 | celerybeat-schedule
123 | celerybeat.pid
124 |
125 | # SageMath parsed files
126 | *.sage.py
127 |
128 | # Environments
129 | .env
130 | .venv
131 | env/
132 | venv/
133 | ENV/
134 | env.bak/
135 | venv.bak/
136 |
137 | # Spyder project settings
138 | .spyderproject
139 | .spyproject
140 |
141 | # Rope project settings
142 | .ropeproject
143 |
144 | # mkdocs documentation
145 | /site
146 |
147 | # mypy
148 | .mypy_cache/
149 | .dmypy.json
150 | dmypy.json
151 |
152 | # Pyre type checker
153 | .pyre/
154 |
155 | # pytype static type analyzer
156 | .pytype/
157 |
158 | # Cython debug symbols
159 | cython_debug/
160 |
161 | # ruff
162 | .ruff_cache/
163 |
164 | # LSP config files
165 | pyrightconfig.json
166 |
167 | # PyCharm
168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
170 | # and can be added to the global gitignore or merged into this file. For a more nuclear
171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
172 | #.idea/
173 |
174 | # VisualStudioCode
175 | .vscode/*
176 | !.vscode/settings.json
177 | !.vscode/tasks.json
178 | !.vscode/launch.json
179 | !.vscode/extensions.json
180 | !.vscode/*.code-snippets
181 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Akirami
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |

5 |
6 |
7 |
8 |
9 | # nonebot-plugin-twitter
10 |
11 | _✨ 推文订阅推送插件 ✨_
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |

21 |
22 |
23 |
24 | ~~⚠ 插件暂不可用~~
25 |
26 | ~~因推特开启登录墙,该插件暂不可用~~
27 |
28 | ## 📖 介绍
29 |
30 | 订阅推送 twitter 推文
31 |
32 | ## 💿 安装
33 |
34 |
35 | 使用包管理器安装
36 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
37 |
38 |
39 | pip
40 |
41 | pip install nonebot-plugin-twitter
42 |
43 |
44 | pdm
45 |
46 | pdm add nonebot-plugin-twitter
47 |
48 |
49 | poetry
50 |
51 | poetry add nonebot-plugin-twitter
52 |
53 |
54 | conda
55 |
56 | conda install nonebot-plugin-twitter
57 |
58 |
59 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
60 |
61 | plugins = ["nonebot_plugin_twitter"]
62 |
63 |
64 |
65 | ## ⚙️ 配置
66 |
67 |
68 | 在 nonebot2 项目的`.env`文件中添加下表中的必填配置
69 |
70 | | 配置项 | 必填 | 默认值 | 说明 |
71 | |:-----:|:----:|:----:|:----:|
72 | | twitter_website | 否 | 无 | 自定义website |
73 | | twitter_proxy | 否 | 无 | proxy |
74 | | twitter_qq | 否 | 2854196310 | 合并消息头像来源 |
75 | | command_priority | 否 | 10 | 命令优先级 |
76 | | twitter_htmlmode | 否 | false | 网页截图模式 |
77 | | twitter_original | 否 | false | 使用x官网截图 |
78 | | twitter_no_text | 否 | false | 开启媒体过滤后彻底不输出文字 |
79 | | twitter_node | 否 | true | 使用合并转发消息发送 |
80 |
81 | 配置格式示例
82 | ```bash
83 | # twitter
84 | twitter_proxy="http://127.0.0.1:1090"
85 | twitter_qq=2854196306
86 | command_priority=10
87 |
88 | # 使用截图纯图模式示例
89 | twitter_htmlmode=true
90 | twitter_original=false
91 | twitter_no_text=true
92 | twitter_node=false
93 | ```
94 |
95 | ## 🎉 使用
96 | ### 指令表
97 | | 指令 | 权限 | 需要@ | 范围 | 说明 |
98 | |:-----:|:----:|:----:|:----:|:----:|
99 | | 关注推主 | 无 | 否 | 群聊/私聊 | 关注,指令格式:“关注推主 <推主id> [r18] [媒体]”|
100 | | 取关推主 | 无 | 否 | 群聊/私聊 | 取关切割 |
101 | | 推主列表 | 无 | 否 | 群聊/私聊 | 展示列表 |
102 | | 推文列表 | 无 | 否 | 群聊/私聊 | 展示最多5条时间线推文,指令格式:“推文列表 <推主id>” |
103 | | 推文推送关闭 | 无 | 否 | 群聊/私聊 | 关闭推送 |
104 | | 推文推送开启 | 无 | 否 | 群聊/私聊 | 开启推送 |
105 | | 推文链接识别关闭 | 无 | 否 | 群聊 | 关闭链接识别 |
106 | | 推文链接识别开启 | 无 | 否 | 群聊 | 开启链接识别 |
107 |
108 |
109 | [] 为可选参数,
110 |
111 | r18 : 开启r18推文推送
112 |
113 | 媒体 : 仅推送媒体消息
114 |
115 | ### 效果图
116 | [](https://imgse.com/i/pCPuhWV)
117 | [](https://imgse.com/i/pCPu4zT)
118 | ### 注意事项
119 | 1.推主id:
120 | [](https://imgse.com/i/pCPMu36)
121 |
122 | 2.消息为合并转发发送,存在延迟和发送失败的可能
123 |
124 | 3.新的0.1.0版本为破坏性更新:代理配置格式更改,关注列表需重新关注。
125 |
126 | 4.已知bug,视频无法发送(可能为gocq bug)
127 |
128 | 5.链接识别发送方式与配置文件配置有关
129 |
130 | 6.推文列表暂时仅在 网页截图模式 开启时支持
131 |
132 | ### 更新记录
133 | 2024.01.20 0.2.4
134 | 1. 优化代理设置
135 | 2. 添加链接识别功能
136 | 3. 添加查看时间线截图功能
137 |
138 |
139 | 2024.01.14 0.2.3
140 | 1. 修复内存溢出bug
141 | 2. 修复代理未完全生效bug
142 |
143 |
144 | 2024.01.06 0.2.2
145 | 1. 更新默认镜像站列表
146 | 2. 调整文字输出,不再会输出评论区文字
147 | 3. 调整合并转发消息内,图片的优先级(其实是上次更新内容,但忘写了)
148 | 4. 调整自动切换镜像站(非指定website的情况下)
149 |
150 |
151 | 2024.01.01 0.2.0
152 | 1. 增加截图模式
153 | 2. 增加无文字的媒体过滤
154 | 3. 增加非合并转发发送方式
155 | 4. 调整缓存删除方式为每天早上删除(没什么用,现在发不出视频)
156 | 5. 调整媒体图片输出不再会输出评论区他人的图片视频
157 | 6. 优化了日志输出
158 | 7. 还有什么有点忘了,一口气改到0点,祝大家2024新年快乐吧
159 |
160 |
161 | 2023.10.28 0.1.14
162 | 1. 更新可用站点列表
163 |
164 |
165 | 2023.09.16 0.1.13
166 | 1. 暂无更新,可在env配置文件中添加以下参数来解决不可用问题
167 | ```bash
168 | # twitter
169 | twitter_website="https://nitter.privacydev.net"
170 | ```
171 |
172 | 最近找工作忙,更新慢了请见谅
173 |
174 |
175 | 2023.07.28 0.1.13
176 | 1. 修复bug
177 |
178 |
179 | 2023.07.25
180 |
181 | 1. 优化推送消息发送方式
182 | 2. 修复bug
183 |
184 | 2023.07.20
185 |
186 | 1. 增加了仅媒体推送
187 | 2. 修复了该插件与若干问题
188 |
189 |
190 | 2023.06.27
191 |
192 | 1. 临时解决回复原推文时,无法推送全部推文的问题
193 |
--------------------------------------------------------------------------------
/nonebot_plugin_twitter/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | from nonebot import on_regex, require,on_command,get_driver
4 | require("nonebot_plugin_apscheduler")
5 | from nonebot_plugin_apscheduler import scheduler
6 | from nonebot.adapters.onebot.v11 import Message,MessageEvent,Bot,GroupMessageEvent,MessageSegment
7 | from nonebot.matcher import Matcher
8 | from nonebot.params import CommandArg,RegexStr
9 | from nonebot.log import logger
10 | from nonebot.adapters.onebot.v11.adapter import Adapter
11 | from nonebot.exception import FinishedException
12 | from nonebot.plugin import PluginMetadata
13 | from pathlib import Path
14 | import json
15 | import random
16 | from httpx import AsyncClient,Client
17 | import asyncio
18 | from playwright.async_api import async_playwright
19 | from .config import Config,__version__,website_list,config_dev
20 | from .api import *
21 |
22 |
23 | __plugin_meta__ = PluginMetadata(
24 | name="twitter 推特订阅",
25 | description="订阅 twitter 推文",
26 | usage="""
27 | | 指令 | 权限 | 需要@ | 范围 | 说明 |
28 | |:-----:|:----:|:----:|:----:|:----:|
29 | | 关注推主 | 无 | 否 | 群聊/私聊 | 关注,指令格式:“关注推主 <推主id> [r18] [媒体]”|
30 | | 取关推主 | 无 | 否 | 群聊/私聊 | 取关切割 |
31 | | 推主列表 | 无 | 否 | 群聊/私聊 | 展示列表 |
32 | | 推文列表 | 无 | 否 | 群聊/私聊 | 展示最多5条时间线推文,指令格式:“推文列表 <推主id>” |
33 | | 推文推送关闭 | 无 | 否 | 群聊/私聊 | 关闭推送 |
34 | | 推文推送开启 | 无 | 否 | 群聊/私聊 | 开启推送 |
35 | | 推文链接识别关闭 | 无 | 否 | 群聊 | 关闭链接识别 |
36 | | 推文链接识别开启 | 无 | 否 | 群聊 | 开启链接识别 |
37 | """,
38 | type="application",
39 | config=Config,
40 | homepage="https://github.com/nek0us/nonebot-plugin-twitter",
41 | supported_adapters={"~onebot.v11"},
42 | extra={
43 | "author":"nek0us",
44 | "version":__version__,
45 | "priority":config_dev.command_priority
46 | }
47 | )
48 |
49 | web_list = []
50 | if config_dev.twitter_website:
51 | logger.info("使用自定义 website")
52 | web_list.append(config_dev.twitter_website)
53 | web_list += website_list
54 |
55 | get_driver = get_driver()
56 | @get_driver.on_startup
57 | async def pywt_init():
58 | if config_dev.twitter_htmlmode:
59 | if not await is_firefox_installed():
60 | logger.info("Firefox browser is not installed, installing...")
61 | install_firefox()
62 | logger.info("Firefox browser has been successfully installed.")
63 |
64 | async def create_browser():
65 | playwright_manager = async_playwright()
66 | playwright = await playwright_manager.start()
67 | browser = await playwright.firefox.launch(slow_mo=50,proxy=config_dev.twitter_pywt_proxy)
68 | return playwright,browser
69 |
70 |
71 | with Client(proxies=config_dev.twitter_proxy,http2=True) as client:
72 | for url in web_list:
73 | try:
74 | res = client.get(f"{url}/elonmusk/status/1741087997410660402")
75 | if res.status_code == 200:
76 | logger.info(f"website: {url} ok!")
77 | config_dev.twitter_url = url
78 | break
79 | else:
80 | logger.info(f"website: {url} failed!")
81 | except Exception as e:
82 | logger.debug(f"website选择异常:{e}")
83 | continue
84 |
85 | # 清理垃圾
86 | @scheduler.scheduled_job("cron",hour="5")
87 | def clean_pic_cache():
88 | path = Path() / "data" / "twitter" / "cache"
89 | filenames = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path,f))]
90 | timeline = int(datetime.now().timestamp()) - 60 * 60 * 5
91 | [os.remove(path / f) for f in filenames if int(f.split(".")[0]) <= timeline]
92 |
93 |
94 |
95 | if config_dev.plugin_enabled:
96 | if not config_dev.twitter_url:
97 | logger.debug(f"website 推文服务器为空,跳过推文定时检索")
98 | else:
99 | @scheduler.scheduled_job("interval",minutes=3,id="twitter",misfire_grace_time=179)
100 | async def now_twitter():
101 | playwright,browser = await create_browser()
102 | max_duration = 160
103 | twitter_list = json.loads(dirpath.read_text("utf8"))
104 | twitter_list_task = [
105 | get_status(user_name, twitter_list, browser) for user_name in twitter_list
106 | ]
107 | try:
108 | # 等待任务完成或超时
109 | result = await asyncio.wait_for(asyncio.gather(*twitter_list_task), timeout=max_duration)
110 | # result = await asyncio.gather(*twitter_list_task)
111 | if config_dev.twitter_website == "":
112 | # 使用默认镜像站
113 | true_count = sum(1 for elem in result if elem)
114 | if true_count < len(result) / 2:
115 | config_dev.twitter_url = get_next_element(website_list,config_dev.twitter_url)
116 | logger.debug(f"检测到当前镜像站出错过多,切换镜像站至:{config_dev.twitter_url}")
117 | except asyncio.TimeoutError:
118 | # 任务超时,执行超时逻辑
119 | logger.warning(f"twitter 获取超时")
120 | except Exception as e:
121 | # 任务出错
122 | logger.warning(f"twitter 任务出错{e}")
123 | finally:
124 | await browser.close()
125 | await playwright.stop()
126 | # logger.debug(f"twitter 关闭浏览器成功")
127 |
128 | async def get_status(user_name,twitter_list,browser:Browser) -> bool:
129 | # 获取推文
130 | try:
131 | line_new_tweet_id = await get_user_newtimeline(user_name,twitter_list[user_name]["since_id"])
132 | if line_new_tweet_id and line_new_tweet_id != "not found":
133 | # update tweet
134 | tweet_info = await get_tweet(browser,user_name,line_new_tweet_id)
135 | return await tweet_handle(tweet_info,user_name,line_new_tweet_id,twitter_list)
136 | return True
137 | except Exception as e:
138 | logger.debug(f"获取 {user_name} 的推文出现异常:{e}")
139 | return False
140 |
141 |
142 | save = on_command("关注推主",block=True,priority=config_dev.command_priority)
143 | @save.handle()
144 | async def save_handle(bot:Bot,event: MessageEvent,matcher: Matcher,arg: Message = CommandArg()):
145 | if not config_dev.twitter_url:
146 | await matcher.finish("website 推文服务器访问失败,请检查连通性或代理")
147 | data = []
148 | if " " in arg.extract_plain_text():
149 | data = arg.extract_plain_text().split(" ")
150 | else:
151 | data.append(arg.extract_plain_text())
152 | data.append("")
153 | user_info = await get_user_info(data[0])
154 |
155 | if not user_info["status"]:
156 | await matcher.finish(f"未找到 {data[0]}")
157 |
158 | tweet_id = await get_user_newtimeline(data[0])
159 |
160 | twitter_list = json.loads(dirpath.read_text("utf8"))
161 | if isinstance(event,GroupMessageEvent):
162 | if data[0] not in twitter_list:
163 | twitter_list[data[0]] = {
164 | "group":{
165 | str(event.group_id):{
166 | "status":True,
167 | "r18":True if 'r18' in data[1:] else False,
168 | "media":True if '媒体' in data[1:] else False
169 | }
170 | },
171 | "private":{}
172 | }
173 | else:
174 | twitter_list[data[0]]["group"][str(event.group_id)] = {
175 | "status":True,
176 | "r18":True if 'r18' in data[1:] else False,
177 | "media":True if '媒体' in data[1:] else False
178 | }
179 | else:
180 | if data[0] not in twitter_list:
181 | twitter_list[data[0]] = {
182 | "group":{},
183 | "private":{
184 | str(event.user_id):{
185 | "status":True,
186 | "r18":True if 'r18' in data[1:] else False,
187 | "media":True if '媒体' in data[1:] else False
188 | }
189 | }
190 | }
191 | else:
192 | twitter_list[data[0]]["private"][str(event.user_id)] = {
193 | "status":True,
194 | "r18":True if 'r18' in data[1:] else False,
195 | "media":True if '媒体' in data[1:] else False
196 | }
197 |
198 | twitter_list[data[0]]["since_id"] = tweet_id
199 | twitter_list[data[0]]["screen_name"] = user_info["screen_name"]
200 | dirpath.write_text(json.dumps(twitter_list))
201 | await matcher.finish(f"id:{data[0]}\nname:{user_info['screen_name']}\n{user_info['bio']}\n订阅成功")
202 |
203 |
204 | delete = on_command("取关推主",block=True,priority=config_dev.command_priority)
205 | @delete.handle()
206 | async def delete_handle(bot:Bot,event: MessageEvent,matcher: Matcher,arg: Message = CommandArg()):
207 | twitter_list = json.loads(dirpath.read_text("utf8"))
208 | if arg.extract_plain_text() not in twitter_list:
209 | await matcher.finish(f"未找到 {arg}")
210 |
211 | if isinstance(event,GroupMessageEvent):
212 | if str(event.group_id) not in twitter_list[arg.extract_plain_text()]["group"]:
213 | await matcher.finish(f"本群未订阅 {arg}")
214 |
215 | twitter_list[arg.extract_plain_text()]["group"].pop(str(event.group_id))
216 |
217 | else:
218 | if str(event.user_id) not in twitter_list[arg.extract_plain_text()]["private"]:
219 | await matcher.finish(f"未订阅 {arg}")
220 |
221 | twitter_list[arg.extract_plain_text()]["private"].pop(str(event.user_id))
222 | pop_list = []
223 | for user_name in twitter_list:
224 | if twitter_list[user_name]["group"] == {} and twitter_list[user_name]["private"] == {}:
225 | pop_list.append(user_name)
226 |
227 | for user_name in pop_list:
228 | twitter_list.pop(user_name)
229 |
230 | dirpath.write_text(json.dumps(twitter_list))
231 |
232 | await matcher.finish(f"取关 {arg.extract_plain_text()} 成功")
233 |
234 | follow_list = on_command("推主列表",block=True,priority=config_dev.command_priority)
235 | @follow_list.handle()
236 | async def follow_list_handle(bot:Bot,event: MessageEvent,matcher: Matcher):
237 |
238 | twitter_list = json.loads(dirpath.read_text("utf8"))
239 | msg = []
240 |
241 | if isinstance(event,GroupMessageEvent):
242 | for user_name in twitter_list:
243 | if str(event.group_id) in twitter_list[user_name]["group"]:
244 | msg += [
245 | MessageSegment.node_custom(
246 | user_id=config_dev.twitter_qq, nickname=twitter_list[user_name]["screen_name"], content=Message(
247 | f"{user_name} {'r18' if twitter_list[user_name]['group'][str(event.group_id)]['r18'] else ''} {'媒体' if twitter_list[user_name]['group'][str(event.group_id)]['media'] else ''}"
248 | )
249 | )
250 | ]
251 | await bot.send_group_forward_msg(group_id=event.group_id, messages=msg)
252 | else:
253 | for user_name in twitter_list:
254 | if str(event.user_id) in twitter_list[user_name]["private"]:
255 | msg += [
256 | MessageSegment.node_custom(
257 | user_id=config_dev.twitter_qq, nickname=twitter_list[user_name]["screen_name"], content=Message(
258 | f"{user_name} {'r18' if twitter_list[user_name]['private'][str(event.user_id)]['r18'] else ''} {'媒体' if twitter_list[user_name]['private'][str(event.user_id)]['media'] else ''}"
259 | )
260 | )
261 | ]
262 | await bot.send_private_forward_msg(user_id=event.user_id, messages=msg)
263 |
264 | await matcher.finish()
265 |
266 |
267 | async def is_rule(event:MessageEvent) -> bool:
268 | if isinstance(event,GroupMessageEvent):
269 | if event.sender.role in ["owner","admin"]:
270 | return True
271 | return False
272 | else:
273 | return True
274 |
275 | twitter_status = on_command("推文推送",block=True,rule=is_rule,priority=config_dev.command_priority)
276 | @twitter_status.handle()
277 | async def twitter_status_handle(bot:Bot,event: MessageEvent,matcher: Matcher,arg: Message = CommandArg()):
278 | twitter_list = json.loads(dirpath.read_text("utf8"))
279 | try:
280 | if isinstance(event,GroupMessageEvent):
281 | for user_name in twitter_list:
282 | if str(event.group_id) in twitter_list[user_name]["group"]:
283 | if arg.extract_plain_text() == "开启":
284 | twitter_list[user_name]["group"][str(event.group_id)]["status"] = True
285 | elif arg.extract_plain_text() == "关闭":
286 | twitter_list[user_name]["group"][str(event.group_id)]["status"] = False
287 | else:
288 | await matcher.finish("错误指令")
289 | else:
290 | for user_name in twitter_list:
291 | if str(event.user_id) in twitter_list[user_name]["private"]:
292 | if arg.extract_plain_text() == "开启":
293 | twitter_list[user_name]["private"][str(event.user_id)]["status"] = True
294 | elif arg.extract_plain_text() == "关闭":
295 | twitter_list[user_name]["private"][str(event.user_id)]["status"] = False
296 | else:
297 | await matcher.finish("错误指令")
298 | dirpath.write_text(json.dumps(twitter_list))
299 | await matcher.finish(f"推送已{arg.extract_plain_text()}")
300 | except FinishedException:
301 | pass
302 | except Exception as e:
303 | await matcher.finish(f"异常:{e}")
304 |
305 | pat_twitter = on_regex(r'(twitter\.com|x\.com)/[a-zA-Z0-9_]+/status/\d+',priority=config_dev.command_priority)
306 | @pat_twitter.handle()
307 | async def pat_twitter_handle(bot: Bot,event: MessageEvent,matcher: Matcher,text: str = RegexStr()):
308 | logger.info(f"检测到推文链接 {text}")
309 | link_list = json.loads(linkpath.read_text("utf8"))
310 | playwright,browser = await create_browser()
311 | try:
312 | if isinstance(event,GroupMessageEvent):
313 | # 是群,处理一下先
314 | if str(event.group_id) not in link_list:
315 | link_list[str(event.group_id)] = {"link":True}
316 | linkpath.write_text(json.dumps(link_list))
317 |
318 | if not link_list[str(event.group_id)]["link"]:
319 | # 关闭了链接识别
320 | logger.info(f"根据群设置,不获取推文链接内容 {text}")
321 | await matcher.finish()
322 | # 处理完了 继续
323 |
324 | # x.com/username/status/tweet_id
325 | tmp = text.split("/")
326 | user_name = tmp[1]
327 | tweet_id = tmp[-1]
328 |
329 | tweet_info = await get_tweet(browser,user_name,tweet_id)
330 | msg = await tweet_handle_link(tweet_info,user_name,tweet_id)
331 | if config_dev.twitter_node:
332 | if isinstance(event,GroupMessageEvent):
333 | await bot.send_group_forward_msg(group_id=int(event.group_id), messages=msg)
334 | else:
335 | await bot.send_private_forward_msg(user_id=int(event.user_id), messages=msg)
336 | else:
337 | await matcher.send(msg, reply_message=True)
338 | except FinishedException:
339 | pass
340 | except Exception as e:
341 | await matcher.send(f"异常:{e}")
342 | finally:
343 | await browser.close()
344 | await playwright.stop()
345 | await matcher.finish()
346 |
347 | twitter_link = on_command("推文链接识别",priority=config_dev.command_priority)
348 | @twitter_link.handle()
349 | async def twitter_link_handle(event: GroupMessageEvent,matcher: Matcher,arg: Message = CommandArg()):
350 | link_list = json.loads(linkpath.read_text("utf8"))
351 | if str(event.group_id) not in link_list:
352 | link_list[str(event.group_id)] = {"link":True}
353 | linkpath.write_text(json.dumps(link_list))
354 | if "开启" in arg.extract_plain_text():
355 | link_list[str(event.group_id)]["link"] = True
356 | elif "关闭" in arg.extract_plain_text():
357 | link_list[str(event.group_id)]["link"] = False
358 | else:
359 | await matcher.finish("仅支持“开启”和“关闭”操作")
360 | linkpath.write_text(json.dumps(link_list))
361 | await matcher.finish(f"推文链接识别已{arg.extract_plain_text()}")
362 |
363 | twitter_timeline = on_command("推文列表",priority=config_dev.command_priority)
364 | @twitter_timeline.handle()
365 | async def twitter_timeline_handle(bot: Bot,event: MessageEvent,matcher: Matcher,arg: Message = CommandArg()):
366 | if not config_dev.twitter_htmlmode:
367 | await matcher.finish(f"暂时仅支持html模式,请先联系超级管理员开启")
368 | user_info = await get_user_info(arg.extract_plain_text())
369 | if not user_info["status"]:
370 | await matcher.finish(f"未找到 {arg.extract_plain_text()}")
371 | new_line = await get_user_timeline(user_info["user_name"])
372 | if "not found" in new_line:
373 | await matcher.finish(f"未找到 {arg.extract_plain_text()} 存在推文时间线")
374 | if len(new_line) > 5:
375 | new_line = new_line[:5]
376 | playwright,browser = await create_browser()
377 | try:
378 | screen = await get_timeline_screen(browser,user_info["user_name"],len(new_line))
379 | if not screen:
380 | await matcher.finish("好像失败了...")
381 | await matcher.send(MessageSegment.image(file=screen))
382 | except FinishedException:
383 | pass
384 | except Exception as e:
385 | await matcher.send(f"异常:{e}")
386 | finally:
387 | await browser.close()
388 | await playwright.stop()
389 | await matcher.finish()
--------------------------------------------------------------------------------
/nonebot_plugin_twitter/api.py:
--------------------------------------------------------------------------------
1 | import json
2 | import random
3 | import sys
4 | import typing
5 | import httpx
6 | import os
7 | from typing import Optional,Literal
8 | from pathlib import Path
9 | from datetime import datetime
10 | from bs4 import BeautifulSoup
11 | from nonebot import logger
12 | from nonebot.adapters.onebot.v11 import MessageSegment,Message
13 | from playwright.async_api import async_playwright,Browser
14 | from nonebot_plugin_sendmsg_by_bots import tools
15 | from .config import config_dev,twitter_post,twitter_login,nitter_head,nitter_foot,SetCookieParam
16 |
17 | # Path
18 | dirpath = Path() / "data" / "twitter"
19 | dirpath.mkdir(parents=True, exist_ok=True)
20 | dirpath = Path() / "data" / "twitter" / "cache"
21 | dirpath.mkdir(parents=True, exist_ok=True)
22 | dirpath = Path() / "data" / "twitter" / "twitter_list.json"
23 | dirpath.touch()
24 | if not dirpath.stat().st_size:
25 | dirpath.write_text("{}")
26 | linkpath = Path() / "data" / "twitter" / "twitter_link.json"
27 | linkpath.touch()
28 | if not linkpath.stat().st_size:
29 | linkpath.write_text("{}")
30 |
31 | header = {
32 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
33 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
34 | "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
35 | "Accept-Encoding": "gzip, deflate, br",
36 | "Connection": "keep-alive",
37 | "Upgrade-Insecure-Requests": "1",
38 | "Sec-Fetch-Dest": "document",
39 | "Sec-Fetch-Mode": "navigate",
40 | "Sec-Fetch-Site": "cross-site",
41 | "Pragma": "no-cache",
42 | "Cache-Control": "no-cache",
43 | }
44 |
45 | async def get_user_info(user_name:str) -> dict:
46 | '''通过 user_name 获取信息详情,
47 | return:
48 | result["status"],
49 | result["user_name"],
50 | result["screen_name"],
51 | result["bio"]
52 | '''
53 | result ={}
54 | result["status"] = False
55 | try:
56 | async with httpx.AsyncClient(proxies=config_dev.twitter_proxy,http2=True,timeout=120) as client:
57 | res = await client.get(url=f"{config_dev.twitter_url}/{user_name}",headers=header)
58 |
59 | if res.status_code ==200:
60 | result["status"] = True
61 | result["user_name"] = user_name
62 | soup = BeautifulSoup(res.text,"html.parser")
63 | result["screen_name"] = match[0].text if (match := soup.find_all('a', class_='profile-card-fullname')) else ""
64 | result["bio"] = match[0].text if (match := soup.find_all('p')) else ""
65 | else:
66 | logger.warning(f"通过 user_name {user_name} 获取信息详情失败:{res.status_code} {res.text} ")
67 | result["status"] = False
68 | except Exception as e:
69 | logger.warning(f"通过 user_name {user_name} 获取信息详情出错:{e}")
70 |
71 | return result
72 |
73 | async def get_user_timeline(user_name:str,since_id: str = "0"):
74 | async with httpx.AsyncClient(proxies=config_dev.twitter_proxy,http2=True,timeout=120) as client:
75 | res = await client.get(url=f"{config_dev.twitter_url}/{user_name}",headers=header)
76 | if res.status_code ==200:
77 | soup = BeautifulSoup(res.text,"html.parser")
78 | timeline_list = soup.find_all('a', class_='tweet-link')
79 | new_line =[]
80 | for x in timeline_list:
81 | if user_name in x.attrs["href"]:
82 | tweet_id = x.attrs["href"].split("/").pop().replace("#m","")
83 | if since_id != "0":
84 | if int(tweet_id) > int(since_id):
85 | logger.trace(f"通过 user_name {user_name} 获取时间线成功:{tweet_id}")
86 | new_line.append(tweet_id)
87 | else:
88 | new_line.append(tweet_id)
89 |
90 | else:
91 | logger.warning(f"通过 user_name {user_name} 获取时间线失败:{res.status_code} {res.text}")
92 | new_line = ["not found"]
93 | return new_line
94 |
95 | async def get_user_newtimeline(user_name:str,since_id: str = "0") -> str:
96 | ''' 通过 user_name 获取推文id列表,
97 | 有 since_id return 最近的新的推文id,
98 | 无 since_id return 最新的推文id'''
99 | try:
100 | new_line = await get_user_timeline(user_name, since_id)
101 | if since_id == "0":
102 | if new_line == []:
103 | new_line.append("1")
104 | else:
105 | new_line = [str(max(map(int,new_line)))]
106 | if new_line == []:
107 | new_line = ["not found"]
108 | return new_line[-1]
109 | except Exception as e:
110 | logger.warning(f"通过 user_name {user_name} 获取时间线失败:{e}")
111 | raise e
112 |
113 | async def get_timeline_screen(browser: Browser,user_name: str,length: int = 5):
114 | url=f"{config_dev.twitter_url}/{user_name}"
115 | context = await browser.new_context()
116 | page = await context.new_page()
117 | await page.goto(url,timeout=60000)
118 | await page.wait_for_load_state("load",timeout=60000)
119 | await page.wait_for_selector('.timeline-item')
120 |
121 | tweets = await page.query_selector_all('.timeline-item')
122 | first_five_tweets = tweets[:5]
123 |
124 | if len(first_five_tweets) > 0:
125 | first_bbox = await first_five_tweets[0].bounding_box()
126 | fifth_bbox = await first_five_tweets[4].bounding_box()
127 | if first_bbox and fifth_bbox:
128 | # 计算前五个推文所占的总高度
129 | total_height = fifth_bbox['y'] + fifth_bbox['height'] - first_bbox['y']
130 |
131 | # 调整浏览器视口的高度
132 | await page.set_viewport_size({'width': 1280, 'height': total_height})
133 |
134 | # 再次计算第一个推文的位置和尺寸,因为视口大小变化后位置可能会有所变动
135 | first_bbox = await first_five_tweets[0].bounding_box()
136 |
137 | # 截图
138 | screen = await page.screenshot(clip={
139 | 'x': first_bbox['x'],
140 | 'y': first_bbox['y'],
141 | 'width': first_bbox['width'],
142 | 'height': total_height
143 | })
144 |
145 | return screen
146 | return None
147 |
148 | async def get_tweet(browser: Browser,user_name:str,tweet_id: str = "0") -> dict:
149 | '''通过 user_name 和 tweet_id 获取推文详情,
150 | return:
151 | result["status"],
152 | result["text"],
153 | result["pic_url_list"],
154 | result["video_url"],
155 | result["r18"]
156 | result["html"]
157 | '''
158 | try:
159 | result = {}
160 | result["status"] = False
161 | result["html"] = b""
162 | result["media"] = False
163 | url=f"{config_dev.twitter_url}/{user_name}/status/{tweet_id}"
164 |
165 | if config_dev.twitter_htmlmode:
166 | context = await browser.new_context()
167 | page = await context.new_page()
168 | cookie: typing.List[SetCookieParam] = [{
169 | "url": config_dev.twitter_url,
170 | "name": "hlsPlayback",
171 | "value": "on"}]
172 | await context.add_cookies(cookie)
173 | if config_dev.twitter_original:
174 | # 原版 twitter
175 | url=f"https://twitter.com/{user_name}/status/{tweet_id}"
176 | await page.goto(url,timeout=60000)
177 | await page.wait_for_load_state("load",timeout=60000)
178 | await page.evaluate(twitter_login)
179 | await page.evaluate(twitter_post)
180 | screenshot_bytes = await page.locator("xpath=/html/body/div[1]/div/div/div[2]/main/div/div/div/div[1]/div/section/div/div/div[1]").screenshot()
181 | else:
182 | await page.goto(url,timeout=60000)
183 | await page.wait_for_load_state("load",timeout=60000)
184 | await page.evaluate(nitter_head)
185 | await page.evaluate(nitter_foot)
186 | screenshot_bytes = await page.locator("xpath=/html/body/div[1]/div").screenshot()
187 | logger.info(f"使用浏览器截图获取 {url} 推文信息成功")
188 | result["html"] = screenshot_bytes
189 | await page.close()
190 | await context.close()
191 |
192 |
193 | async with httpx.AsyncClient(proxies=config_dev.twitter_proxy,http2=True,timeout=120) as client:
194 | res = await client.get(url,cookies={"hlsPlayback": "on"},headers=header)
195 | if res.status_code ==200:
196 | soup = BeautifulSoup(res.text,"html.parser")
197 |
198 | # text && pic && video
199 | result["text"] = []
200 | result["pic_url_list"] = []
201 | result["video_url"] = ""
202 | if main_thread_div := soup.find('div', class_='main-thread'):
203 | # pic
204 | if pic_list := main_thread_div.find_all('a', class_='still-image'): # type: ignore
205 | result["pic_url_list"] = [x.attrs["href"] for x in pic_list]
206 | # video
207 | if video_list := main_thread_div.find_all('video'): # type: ignore
208 | # result["video_url"] = video_list[0].attrs["data-url"]
209 | try:
210 | video_url = video_list[0].parent.parent.parent.parent.parent.contents[1].attrs["href"].replace("#m","")
211 | except Exception as e:
212 | logger.info(f"获取视频推文链接出错,转为获取自身链接,{e}")
213 | video_url = url.split(config_dev.twitter_url)[1]
214 | result["video_url"] = f"https://twitter.com{video_url}"
215 | # text
216 | if match := main_thread_div.find_all('div', class_='tweet-content media-body'): # type: ignore
217 | for x in match:
218 | if x.parent.attrs["class"] == "replying-to":
219 | continue
220 | result["text"].append(x.text)
221 | # r18
222 | result["r18"] = bool(r18 := soup.find_all('div', class_='unavailable-box'))
223 | if result["video_url"] or result["pic_url_list"]:
224 | result["media"] = True
225 | logger.info(f"推主 {user_name} 的推文 {tweet_id} 存在媒体")
226 | result["status"] = True
227 | logger.info(f"推主 {user_name} 的推文 {tweet_id} 获取成功")
228 | else:
229 | logger.warning(f"获取 {user_name} 的推文 {tweet_id} 失败:{res.status_code} {res.text}")
230 | return result
231 | except Exception as e:
232 | logger.warning(f"获取 {user_name} 的推文 {tweet_id} 异常:{e}")
233 | raise e
234 |
235 |
236 | async def get_video_path(url: str) -> str:
237 | try:
238 | filename = str(int(datetime.now().timestamp())) + ".mp4"
239 | path = Path() / "data" / "twitter" / "cache" / filename
240 | path = f"{os.getcwd()}/{str(path)}"
241 | async with httpx.AsyncClient(proxies=config_dev.twitter_proxy) as client:
242 | res = await client.get(f"https://twitterxz.com/?url={url}",headers=header,timeout=120)
243 | if res.status_code != 200:
244 | raise ValueError("视频下载失败")
245 | soup = BeautifulSoup(res.text,"html.parser")
246 | script_tag = soup.find('script', {'id': '__NEXT_DATA__', 'type': 'application/json'})
247 | tmp = script_tag.string # type: ignore
248 | video_url = json.loads(tmp) # type: ignore
249 | video_url = video_url['props']['pageProps']['twitterInfo']['videoInfos'][-1]['url']
250 | # return video_url
251 | async with client.stream("get",video_url) as s:
252 | if res.status_code != 200:
253 | raise ValueError("视频下载失败")
254 |
255 | with open(path,'wb') as file:
256 | async for chunk in s.aiter_bytes():
257 | file.write(chunk)
258 |
259 | return path
260 | except Exception as e:
261 | logger.warning(f"下载视频异常:url {url},{e}")
262 | raise e
263 |
264 | async def get_video(url: str) -> MessageSegment:
265 | '修改为返回视频消息,而非合并视频消息'
266 | # return MessageSegment.node_custom(user_id=user_id, nickname=name,
267 | # content=Message(MessageSegment.video(f"file:///{task}")))
268 | try:
269 | path = await get_video_path(url)
270 | # return MessageSegment.video(path)
271 | return MessageSegment.video(f"file:///{path}")
272 | except Exception as e:
273 | logger.debug(f"缓存视频异常:url {url},{e},转为返回原链接")
274 | return MessageSegment.text(f"获取视频出错啦,原链接:{url}")
275 |
276 | async def get_pic(url: str) -> MessageSegment:
277 | '修改为返回图片消息,而非合并图片消息'
278 |
279 | async with httpx.AsyncClient(proxies=config_dev.twitter_proxy,http2=True) as client:
280 | try:
281 | res = await client.get(f"{config_dev.twitter_url}{url}",headers=header,timeout=120)
282 | if res.status_code != 200:
283 | logger.warning(f"图片下载失败:{config_dev.twitter_url}{url},状态码:{res.status_code}")
284 | # return MessageSegment.node_custom(user_id=config_dev.twitter_qq, nickname=user_name,
285 | # content=Message(f"图片加载失败 X_X {url}"))
286 | return MessageSegment.text(f"图片加载失败 X_X 图片链接 {config_dev.twitter_url}{url}")
287 | tmp = bytes(random.randint(0,255))
288 | # return MessageSegment.node_custom(user_id=config_dev.twitter_qq, nickname=user_name,
289 | # content=Message(MessageSegment.image(file=(res.read()+tmp))))
290 | return MessageSegment.image(file=(res.read()+tmp))
291 | except Exception as e:
292 | logger.warning(f"获取图片出现异常 {config_dev.twitter_url}{url} :{e}")
293 | return MessageSegment.text(f"图片加载失败 X_X 图片链接 {config_dev.twitter_url}{url}")
294 |
295 |
296 |
297 | async def is_firefox_installed():
298 | '''chekc firefox install | 检测Firefox是否已经安装 '''
299 | try:
300 | playwright_manager = async_playwright()
301 | playwright = await playwright_manager.start()
302 | browser = await playwright.firefox.launch(slow_mo=50)
303 | await browser.close()
304 | return True
305 | except Exception as e:
306 | return False
307 |
308 | # 安装Firefox
309 | def install_firefox():
310 | os.system('playwright install firefox')
311 |
312 |
313 | # 发送
314 | async def send_msg(twitter_list: dict,user_name: str,line_new_tweet_id: str,tweet_info: dict,msg: Message,mode:Optional[Literal["node","direct","video"]] = "node"):
315 | for group_num in twitter_list[user_name]["group"]:
316 | # 群聊
317 | if twitter_list[user_name]["group"][group_num]["status"]:
318 | if twitter_list[user_name]["group"][group_num]["r18"] == False and tweet_info["r18"] == True:
319 | logger.info(f"根据r18设置,群 {group_num} 的推文 {user_name}/status/{line_new_tweet_id} 跳过发送")
320 | continue
321 | if twitter_list[user_name]["group"][group_num]["media"] == True and tweet_info["media"] == False:
322 | logger.info(f"根据媒体设置,群 {group_num} 的推文 {user_name}/status/{line_new_tweet_id} 跳过发送")
323 | continue
324 | try:
325 | if mode == "node":
326 | # 以合并方式发送
327 | if await tools.send_group_forward_msg_by_bots(group_id=int(group_num), node_msg=msg):
328 | logger.info(f"群 {group_num} 的推文 {user_name}/status/{line_new_tweet_id} 合并发送成功")
329 | elif mode == "direct":
330 | if await tools.send_group_msg_by_bots(group_id=int(group_num), msg=msg):
331 | logger.info(f"群 {group_num} 的推文 {user_name}/status/{line_new_tweet_id} 直接发送成功")
332 | elif mode == "video":
333 | if await tools.send_group_msg_by_bots(group_id=int(group_num), msg=msg):
334 | logger.info(f"群 {group_num} 的推文 {user_name}/status/{line_new_tweet_id} 视频发送成功")
335 | except Exception as e:
336 | logger.warning(f"发送消息出现失败,目标群:{group_num},推文 {user_name}/status/{line_new_tweet_id},发送模式 {'截图' if tweet_info['html'] else '内容'},异常{e}")
337 | else:
338 | logger.info(f"根据通知设置,群 {group_num} 的推文 {user_name}/status/{line_new_tweet_id} 跳过发送")
339 |
340 | for qq in twitter_list[user_name]["private"]:
341 | # 私聊
342 | if twitter_list[user_name]["private"][qq]["status"]:
343 | if twitter_list[user_name]["private"][qq]["r18"] == False and tweet_info["r18"] == True:
344 | logger.info(f"根据r18设置,qq {qq} 的推文 {user_name}/status/{line_new_tweet_id} 跳过发送")
345 | continue
346 | if twitter_list[user_name]["private"][qq]["media"] == True and tweet_info["media"] == False:
347 | logger.info(f"根据媒体设置,qq {qq} 的推文 {user_name}/status/{line_new_tweet_id} 跳过发送")
348 | continue
349 | try:
350 | if mode == "node":
351 | if await tools.send_private_forward_msg_by_bots(user_id=int(qq), node_msg=msg):
352 | logger.info(f"qq {qq} 的推文 {user_name}/status/{line_new_tweet_id} 合并发送成功")
353 | elif mode == "direct":
354 | if await tools.send_private_msg_by_bots(user_id=int(qq), msg=msg):
355 | logger.info(f"qq {qq} 的推文 {user_name}/status/{line_new_tweet_id} 直接发送成功")
356 | elif mode == "video":
357 | if await tools.send_private_msg_by_bots(user_id=int(qq), msg=msg):
358 | logger.info(f"qq {qq} 的推文 {user_name}/status/{line_new_tweet_id} 视频发送成功")
359 | except Exception as e:
360 | logger.warning(f"发送消息出现失败,目标qq:{qq},推文 {user_name}/status/{line_new_tweet_id},发送模式 {'截图' if tweet_info['html'] else '内容'},异常{e}")
361 | else:
362 | logger.info(f"根据通知设置,qq {qq} 的推文 {user_name}/status/{line_new_tweet_id} 跳过发送")
363 |
364 | def get_next_element(my_list, current_element):
365 |
366 | # 获取当前元素在列表中的索引
367 | index = my_list.index(current_element)
368 |
369 | # 计算下一个元素的索引
370 | next_index = (index + 1) % len(my_list)
371 |
372 | # 返回下一个元素
373 | return my_list[next_index]
374 |
375 |
376 | async def get_tweet_context(tweet_info: dict,user_name: str,line_new_tweet_id: str):
377 | all_msg = []
378 |
379 | # html模式
380 | if config_dev.twitter_htmlmode:
381 | bytes_size = sys.getsizeof(tweet_info["html"]) / (1024 * 1024)
382 | all_msg.append(MessageSegment.image(tweet_info["html"]))
383 |
384 | # 返回图片
385 | if tweet_info["pic_url_list"]:
386 | for url in tweet_info["pic_url_list"]:
387 | all_msg.append(await get_pic(url))
388 |
389 | # 视频,返回本地视频路径
390 | if tweet_info["video_url"]:
391 | all_msg.append(await get_video(tweet_info["video_url"]))
392 |
393 | return all_msg
394 |
395 |
396 | async def tweet_handle(tweet_info: dict,user_name: str,line_new_tweet_id: str,twitter_list: dict) -> bool:
397 | if not tweet_info["status"] and not tweet_info["html"]:
398 | # 啥都没获取到
399 | logger.warning(f"{user_name} 的推文 {line_new_tweet_id} 获取失败")
400 | return False
401 | elif not tweet_info["status"] and tweet_info["html"]:
402 | # 起码有个截图
403 | logger.debug(f"{user_name} 的推文 {line_new_tweet_id} 获取失败,但截图成功,准备发送截图")
404 | msg = []
405 | if config_dev.twitter_htmlmode:
406 | # 有截图
407 | bytes_size = sys.getsizeof(tweet_info["html"]) / (1024 * 1024)
408 | msg.append(MessageSegment.image(tweet_info["html"]))
409 | if config_dev.twitter_node:
410 | # 合并转发
411 | msg.append(MessageSegment.node_custom(
412 | user_id=config_dev.twitter_qq,
413 | nickname=twitter_list[user_name]["screen_name"],
414 | content=Message(MessageSegment.image(tweet_info["html"]))
415 | ))
416 | await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(msg))
417 | else:
418 | # 直接发送
419 | await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(msg),"direct")
420 |
421 | return True
422 | return False
423 | # elif tweet_info["status"] and not tweet_info["html"]:
424 | # # 只没有截图?不应该啊
425 | # pass
426 | # elif tweet_info["status"] and tweet_info["html"]:
427 | else:
428 | # 有没有截图不知道,内容信息是真有
429 | all_msg = await get_tweet_context(tweet_info,user_name,line_new_tweet_id)
430 |
431 | # 准备发送消息
432 | if config_dev.twitter_node:
433 | # 以合并方式发送
434 | msg = []
435 | for value in all_msg:
436 | msg.append(
437 | MessageSegment.node_custom(
438 | user_id=config_dev.twitter_qq,
439 | nickname=twitter_list[user_name]["screen_name"],
440 | content=Message(value)
441 | )
442 | )
443 | if not config_dev.twitter_no_text:
444 | # 开启了媒体文字
445 | for x in tweet_info["text"]:
446 | msg.append(MessageSegment.node_custom(
447 | user_id=config_dev.twitter_qq,
448 | nickname=twitter_list[user_name]["screen_name"],
449 | content=
450 | Message(x)
451 | ))
452 | # 发送合并消息
453 | await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(msg))
454 | else:
455 | # 以直接发送的方式
456 | if all_msg[-1].type == "video":
457 | # 有视频先发视频
458 | video_msg = all_msg.pop()
459 | # msg = []
460 | # msg.append(
461 | # MessageSegment.node_custom(
462 | # user_id=config_dev.twitter_qq,
463 | # nickname=twitter_list[user_name]["screen_name"],
464 | # content=Message(video_msg)
465 | # )
466 | # )
467 | # await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(msg),"video")
468 | await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(video_msg),"video")
469 | if not config_dev.twitter_no_text:
470 | # 开启了媒体文字
471 | all_msg.append(MessageSegment.text('\n\n'.join(tweet_info["text"])))
472 | # 剩余部分直接发送
473 | await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(all_msg),"direct")
474 |
475 |
476 | # 更新本地缓存
477 | twitter_list[user_name]["since_id"] = line_new_tweet_id
478 | dirpath.write_text(json.dumps(twitter_list))
479 | return True
480 |
481 |
482 | async def tweet_handle_link(tweet_info: dict,user_name: str,line_new_tweet_id: str):
483 | if not tweet_info["status"] and not tweet_info["html"]:
484 | # 啥都没获取到
485 | logger.warning(f"{user_name} 的推文 {line_new_tweet_id} 获取失败")
486 | return Message(f"{user_name} 的推文 {line_new_tweet_id} 获取失败")
487 | elif not tweet_info["status"] and tweet_info["html"]:
488 | # 起码有个截图
489 | logger.debug(f"{user_name} 的推文 {line_new_tweet_id} 获取失败,但截图成功,准备发送截图")
490 | msg = []
491 | if config_dev.twitter_htmlmode:
492 | # 有截图
493 | bytes_size = sys.getsizeof(tweet_info["html"]) / (1024 * 1024)
494 | msg.append(MessageSegment.image(tweet_info["html"]))
495 | if config_dev.twitter_node:
496 | # 合并转发
497 | msg.append(MessageSegment.node_custom(
498 | user_id=config_dev.twitter_qq,
499 | nickname=user_name,
500 | content=Message(MessageSegment.image(tweet_info["html"]))
501 | ))
502 | # await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(msg))
503 |
504 | return Message(msg)
505 | return Message("")
506 | # elif tweet_info["status"] and not tweet_info["html"]:
507 | # # 只没有截图?不应该啊
508 | # pass
509 | # elif tweet_info["status"] and tweet_info["html"]:
510 | else:
511 | # 有没有截图不知道,内容信息是真有
512 | all_msg = await get_tweet_context(tweet_info,user_name,line_new_tweet_id)
513 |
514 | # 准备发送消息
515 | if config_dev.twitter_node:
516 | # 以合并方式发送
517 | msg = []
518 | for value in all_msg:
519 | msg.append(
520 | MessageSegment.node_custom(
521 | user_id=config_dev.twitter_qq,
522 | nickname=user_name,
523 | content=Message(value)
524 | )
525 | )
526 | if not config_dev.twitter_no_text:
527 | # 开启了媒体文字
528 | for x in tweet_info["text"]:
529 | msg.append(MessageSegment.node_custom(
530 | user_id=config_dev.twitter_qq,
531 | nickname=user_name,
532 | content=
533 | Message(x)
534 | ))
535 | # 发送合并消息
536 | # await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(msg))
537 | return Message(msg)
538 | else:
539 | # 以直接发送的方式
540 | if all_msg[-1].type == "video":
541 | # 有视频先发视频
542 | video_msg = all_msg.pop()
543 | # await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(video_msg),"video")
544 | if not config_dev.twitter_no_text:
545 | # 开启了媒体文字
546 | all_msg.append(MessageSegment.text('\n\n'.join(tweet_info["text"])))
547 | # 剩余部分直接发送
548 | # await send_msg(twitter_list,user_name,line_new_tweet_id,tweet_info,Message(all_msg),"direct")
549 | return Message(all_msg)
550 |
--------------------------------------------------------------------------------
/nonebot_plugin_twitter/config.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, root_validator,validator
2 | from typing import Literal, Optional, TypedDict
3 | from playwright._impl._api_structures import ProxySettings
4 | from nonebot.log import logger
5 | from nonebot import get_driver
6 | import sys
7 |
8 | if sys.version_info < (3, 10):
9 | from importlib_metadata import version
10 | else:
11 | from importlib.metadata import version
12 |
13 | try:
14 | __version__ = version("nonebot_plugin_twitter")
15 | except Exception:
16 | __version__ = None
17 |
18 | class Config(BaseModel):
19 | # 自定义镜像站
20 | twitter_website: Optional[str] = ""
21 | # 代理
22 | twitter_proxy: Optional[str] = None
23 | # playwright 代理
24 | twitter_pywt_proxy: Optional[ProxySettings] = None
25 | # 内部当前使用url
26 | twitter_url: Optional[str] = ""
27 | # 自定义转发消息来源qq
28 | twitter_qq: int = 2854196310
29 | # 自定义事件响应等级
30 | command_priority: int = 10
31 | # 插件开关
32 | plugin_enabled: bool = True
33 | # 网页截图模式
34 | twitter_htmlmode: bool = False
35 | # 截取源地址网页
36 | twitter_original: bool = False
37 | # 媒体无文字
38 | twitter_no_text: bool = False
39 | # 使用转发消息
40 | twitter_node: bool = True
41 |
42 |
43 | @validator("twitter_website")
44 | def check_twitter_website(cls,v):
45 | if isinstance(v,str):
46 | logger.info(f"twitter_website {v} 读取成功")
47 | return v
48 | @validator("twitter_proxy")
49 | def check_proxy(cls,v):
50 | if isinstance(v,str):
51 | logger.info(f"twitter_proxy {v} 读取成功")
52 | return v
53 | @validator("twitter_qq")
54 | def check_twitter_qq(cls,v):
55 | if isinstance(v,int):
56 | logger.info(f"twitter_qq {v} 读取成功")
57 | return v
58 |
59 | @validator("command_priority")
60 | def check_command_priority(cls,v):
61 | if isinstance(v,int) and v >= 1:
62 | logger.info(f"command_priority {v} 读取成功")
63 | return v
64 |
65 | @validator("twitter_original")
66 | def check_twitter_original(cls,v):
67 | if isinstance(v,bool):
68 | logger.info(f"twitter_original 使用twitter官方页面截图 {'已开启' if v else '已关闭'}")
69 | return v
70 |
71 | @validator("twitter_htmlmode")
72 | def check_twitter_htmlmode(cls,v):
73 | if isinstance(v,bool):
74 | logger.info(f"twitter_htmlmode 网页截图模式 {'已开启' if v else '已关闭'}")
75 | return v
76 |
77 | @validator("twitter_no_text")
78 | def check_twitter_no_text(cls,v):
79 | if isinstance(v,bool):
80 | logger.info(f"twitter_no_text 媒体无文字 {'已开启' if v else '已关闭'}")
81 | return v
82 |
83 | @validator("twitter_node")
84 | def check_twitter_node(cls,v):
85 | if isinstance(v,bool):
86 | logger.info(f"twitter_node 合并转发消息 {'已开启' if v else '已关闭'}")
87 | return v
88 |
89 | @root_validator(pre=False)
90 | def set_twitter_pywt_proxy(cls, values):
91 | twitter_proxy = values.get('twitter_proxy')
92 | values['twitter_pywt_proxy'] = {"server": twitter_proxy} if twitter_proxy else None
93 | return values
94 |
95 | config_dev = Config.parse_obj(get_driver().config)
96 |
97 | website_list = [
98 | "https://nitter.mint.lgbt",
99 | "https://nitter.uni-sonia.com",
100 | "https://nitter.poast.org",
101 | "https://nitter.privacydev.net",
102 | "https://nitter.salastil.com",
103 | "https://nitter.d420.de",
104 | "https://nitter.1d4.us",
105 | "https://nitter.moomoo.me"
106 | "https://n.opnxng.com",
107 |
108 | # "https://n.biendeo.com", # 很慢
109 | # "https://nitter.catsarch.com", # 很慢
110 | # "https://nitter.net", # 403
111 | # "https://nitter.dafriser.be", # 502
112 | # "https://nitter.woodland.cafe", # 403
113 | # "https://nitter.x86-64-unknown-linux-gnu.zip", # 403
114 | # "https://bird.trom.tf", # 寄
115 | # "https://nitter.unixfox.eu", # 403
116 | # "https://nitter.it", # 404
117 | # "https://twitter.owacon.moe", # 301
118 |
119 | ]
120 |
121 | twitter_post = '''() => {
122 | const elementXPath = '/html/body/div[1]/div/div/div[2]/main/div/div/div/div[1]/div/div[1]/div[1]/div/div/div/div';
123 | const element = document.evaluate(elementXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
124 |
125 | if (element) {
126 | element.remove();
127 | }
128 | }'''
129 | twitter_login = '''() => {
130 | const elementXPath = '/html/body/div[1]/div/div/div[1]/div/div[1]/div';
131 | const element = document.evaluate(elementXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
132 |
133 | if (element) {
134 | element.remove();
135 | }
136 | }'''
137 |
138 | nitter_head = '''() => {
139 | const elementXPath = '/html/body/nav';
140 | const element = document.evaluate(elementXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
141 |
142 | if (element) {
143 | element.remove();
144 | }
145 | }'''
146 |
147 | nitter_foot = '''() => {
148 | const elementXPath = '/html/body/div[1]/div/div[3]/div';
149 | const element = document.evaluate(elementXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
150 |
151 | if (element) {
152 | element.remove();
153 | }
154 | }'''
155 |
156 | class SetCookieParam(TypedDict, total=False):
157 | name: str
158 | value: str
159 | url: Optional[str]
160 | domain: Optional[str]
161 | path: Optional[str]
162 | expires: Optional[float]
163 | httpOnly: Optional[bool]
164 | secure: Optional[bool]
165 | sameSite: Optional[Literal["Lax", "None", "Strict"]]
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "nonebot-plugin-twitter"
3 | version = "0.2.4"
4 | description = "NoneBot2 plugin for twitter"
5 | authors = [
6 | {name = "nek0us", email = "nekouss@gmail.com"},
7 | ]
8 | license = {text = "MIT"}
9 | dependencies = ["nonebot2>=2.0.0", "nonebot-adapter-onebot>=2.1.3", "httpx>=0.23.0", "httpx[http2]>=0.25.0", "nonebot_plugin_apscheduler>=0.2.0", "beautifulsoup4>=4.11.1", "nonebot_plugin_sendmsg_by_bots>=0.0.4","playwright"]
10 | requires-python = ">=3.8"
11 | readme = "README.md"
12 |
13 | [project.urls]
14 | Homepage = "https://github.com/nek0us/nonebot-plugin-twitter"
15 | Repository = "https://github.com/nek0us/nonebot-plugin-twitter"
16 |
17 | [build-system]
18 | requires = ["pdm-pep517>=0.12.0"]
19 | build-backend = "pdm.pep517.api"
20 |
--------------------------------------------------------------------------------