├── .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 | NoneBotPluginLogo 3 |
4 |

NoneBotPluginText

5 |
6 | 7 |
8 | 9 | # nonebot-plugin-twitter 10 | 11 | _✨ 推文订阅推送插件 ✨_ 12 | 13 | 14 | 15 | license 16 | 17 | 18 | pypi 19 | 20 | python 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 | [![pCPuhWV.png](https://s1.ax1x.com/2023/06/05/pCPuhWV.png)](https://imgse.com/i/pCPuhWV) 117 | [![pCPu4zT.png](https://s1.ax1x.com/2023/06/05/pCPu4zT.png)](https://imgse.com/i/pCPu4zT) 118 | ### 注意事项 119 | 1.推主id: 120 | [![pCPMu36.png](https://s1.ax1x.com/2023/06/05/pCPMu36.png)](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 | --------------------------------------------------------------------------------