download-flow-enmu 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +1 -0
- cli/download_cmd.py +64 -0
- cli/main.py +11 -0
- cli/root.py +25 -0
- config/__init__.py +0 -0
- config/loader.py +58 -0
- data/bill_profile.py +39 -0
- download_flow_enmu-0.1.0.dist-info/METADATA +92 -0
- download_flow_enmu-0.1.0.dist-info/RECORD +25 -0
- download_flow_enmu-0.1.0.dist-info/WHEEL +5 -0
- download_flow_enmu-0.1.0.dist-info/entry_points.txt +2 -0
- download_flow_enmu-0.1.0.dist-info/top_level.txt +6 -0
- factory/downloader_factory.py +8 -0
- factory/email_factory.py +11 -0
- factory/provider_factory.py +9 -0
- package/__init__.py +0 -0
- package/downfile.py +192 -0
- strategy/downloader/__init__.py +0 -0
- strategy/downloader/attchment.py +41 -0
- strategy/downloader/provider.py +9 -0
- strategy/email/__init__.py +0 -0
- strategy/email/email_provider.py +9 -0
- strategy/email/gmail.py +19 -0
- strategy/provider/ali_provider.py +31 -0
- strategy/provider/provider.py +8 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cmd package
|
cli/download_cmd.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
from factory import downloader_factory, email_factory, provider_factory
|
|
7
|
+
from data.bill_profile import PayConfig
|
|
8
|
+
from typing_extensions import Annotated
|
|
9
|
+
from cli.root import app
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(provider: str):
|
|
13
|
+
# 配置日志
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.INFO,
|
|
16
|
+
format='%(levelname)s: %(message)s'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
config_path = Path.home() / ".flow" / "config.json"
|
|
20
|
+
|
|
21
|
+
logging.info(f"开始下载 {provider} 账单...")
|
|
22
|
+
logging.info(f"配置文件路径: {config_path}")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
# 使用 ConfigLoader 加载配置(支持环境变量替换)
|
|
26
|
+
from config.loader import load_config
|
|
27
|
+
config_json = load_config(config_path)
|
|
28
|
+
|
|
29
|
+
logging.info(f"配置加载成功")
|
|
30
|
+
|
|
31
|
+
adapter = TypeAdapter(PayConfig)
|
|
32
|
+
config_obj = adapter.validate_python(config_json[provider])
|
|
33
|
+
downloader = downloader_factory.creat_downloader(config_obj.download)
|
|
34
|
+
email = email_factory.create_emial(
|
|
35
|
+
config_obj.email, config_obj.auth.model_dump(), config_obj.auth_type.value
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
provider_obj = provider_factory.create_workder(provider)
|
|
39
|
+
provider_obj.email = email
|
|
40
|
+
provider_obj.downloader = downloader
|
|
41
|
+
provider_obj.bill = config_obj.profile
|
|
42
|
+
|
|
43
|
+
logging.info(f"开始执行下载...")
|
|
44
|
+
provider_obj.process_bills()
|
|
45
|
+
logging.info(f"✅ 下载完成!")
|
|
46
|
+
|
|
47
|
+
except FileNotFoundError as e:
|
|
48
|
+
logging.error(f"配置文件未找到: {config_path}")
|
|
49
|
+
raise
|
|
50
|
+
except KeyError as e:
|
|
51
|
+
logging.error(f"配置中缺少 provider: {provider}")
|
|
52
|
+
raise
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logging.error(f"处理失败: {e}")
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def download(
|
|
60
|
+
provider: Annotated[
|
|
61
|
+
str, typer.Option("--provider", "-p", help="config file")
|
|
62
|
+
] = "alipay",
|
|
63
|
+
):
|
|
64
|
+
run(provider)
|
cli/main.py
ADDED
cli/root.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
app = typer.Typer(help="demail")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def version_callback(value: bool):
|
|
7
|
+
if value:
|
|
8
|
+
print("Awesome CLI Version: 1.0.0")
|
|
9
|
+
raise typer.Exit()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback()
|
|
13
|
+
def main(
|
|
14
|
+
version: bool = typer.Option(
|
|
15
|
+
None,
|
|
16
|
+
"--version",
|
|
17
|
+
"-v",
|
|
18
|
+
help="Show version",
|
|
19
|
+
callback=version_callback,
|
|
20
|
+
is_eager=True,
|
|
21
|
+
),
|
|
22
|
+
verbose: bool = typer.Option(False, "--verbose", help="Enable verbose output"),
|
|
23
|
+
):
|
|
24
|
+
if verbose:
|
|
25
|
+
print("Verbose mode is ON")
|
config/__init__.py
ADDED
|
File without changes
|
config/loader.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
配置加载工具,支持从环境变量替换配置值
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigLoader:
|
|
13
|
+
"""配置加载器,支持环境变量替换"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, env_file: Path = None):
|
|
16
|
+
if env_file is None:
|
|
17
|
+
# 默认在项目根目录查找 .env
|
|
18
|
+
env_file = Path(__file__).parent.parent / ".env"
|
|
19
|
+
|
|
20
|
+
# 加载 .env 文件
|
|
21
|
+
if env_file.exists():
|
|
22
|
+
load_dotenv(env_file)
|
|
23
|
+
|
|
24
|
+
def _replace_env_vars(self, value: Any) -> Any:
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
# 匹配 ${VAR_NAME} 或 ${VAR_NAME:default}
|
|
27
|
+
pattern = r'\$\{([^}:]+)(?::([^}]*))?\}'
|
|
28
|
+
|
|
29
|
+
def replacer(match):
|
|
30
|
+
var_name = match.group(1)
|
|
31
|
+
default_value = match.group(2) if match.group(2) is not None else ""
|
|
32
|
+
return os.getenv(var_name, default_value)
|
|
33
|
+
|
|
34
|
+
return re.sub(pattern, replacer, value)
|
|
35
|
+
|
|
36
|
+
elif isinstance(value, dict):
|
|
37
|
+
return {k: self._replace_env_vars(v) for k, v in value.items()}
|
|
38
|
+
|
|
39
|
+
elif isinstance(value, list):
|
|
40
|
+
return [self._replace_env_vars(item) for item in value]
|
|
41
|
+
|
|
42
|
+
else:
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
def load_json(self, config_path: Path) -> Dict[str, Any]:
|
|
46
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
47
|
+
config = json.load(f)
|
|
48
|
+
|
|
49
|
+
return self._replace_env_vars(config)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# 便捷函数
|
|
53
|
+
def load_config(config_path: Path = None) -> Dict[str, Any]:
|
|
54
|
+
if config_path is None:
|
|
55
|
+
config_path = Path.home() / ".flow" / "config.json"
|
|
56
|
+
|
|
57
|
+
loader = ConfigLoader()
|
|
58
|
+
return loader.load_json(config_path)
|
data/bill_profile.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pydantic import BaseModel, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BillProfile(BaseModel):
|
|
8
|
+
name: str = ""
|
|
9
|
+
search_subject: str = ""
|
|
10
|
+
sender_email: str = ""
|
|
11
|
+
file_suffix: str = ""
|
|
12
|
+
encoding: str = "utf-8"
|
|
13
|
+
save_subdir: Path = Path.home() / ".flow" / "data"
|
|
14
|
+
|
|
15
|
+
@model_validator(mode="after")
|
|
16
|
+
def set_dynamic_path(self):
|
|
17
|
+
if self.name:
|
|
18
|
+
self.save_subdir = self.save_subdir / self.name
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthType(Enum):
|
|
23
|
+
PASS = "pass"
|
|
24
|
+
TOKEN = "token"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Auth(BaseModel):
|
|
29
|
+
username: str = ""
|
|
30
|
+
password: str = ""
|
|
31
|
+
token: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PayConfig(BaseModel):
|
|
35
|
+
email: str
|
|
36
|
+
download: str
|
|
37
|
+
auth_type: AuthType
|
|
38
|
+
auth: Auth
|
|
39
|
+
profile: BillProfile
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: download-flow-enmu
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A task flow management tool for syncing todo files with taskwarrior
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: typer>=0.9.0
|
|
15
|
+
Requires-Dist: pydantic>=2.0.0
|
|
16
|
+
Requires-Dist: imap_tools
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# tflow
|
|
22
|
+
|
|
23
|
+
A task flow management tool for syncing todo files with taskwarrior.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### Development Installation (Editable Mode)
|
|
28
|
+
|
|
29
|
+
Install the package in editable mode so you can use the `tflow` command anywhere:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# In the project directory
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Regular Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
After installation, you can use the `tflow` command directly:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Show available commands
|
|
48
|
+
tflow --help
|
|
49
|
+
|
|
50
|
+
# Read and import tasks from todo files
|
|
51
|
+
tflow read -c /path/to/files.yaml
|
|
52
|
+
|
|
53
|
+
# Modify tasks
|
|
54
|
+
tflow modfiy -a "new_data" -c "command"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Create a `files.yaml` configuration file to specify your todo file locations:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
sources:
|
|
63
|
+
- id: "project1"
|
|
64
|
+
type: "static"
|
|
65
|
+
path:
|
|
66
|
+
darwin: "/path/to/project1.todo"
|
|
67
|
+
wsl: "/mnt/path/to/project1.todo"
|
|
68
|
+
- id: "daily"
|
|
69
|
+
type: "daily"
|
|
70
|
+
path:
|
|
71
|
+
darwin: "/path/to/agenda/todo"
|
|
72
|
+
wsl: "/mnt/path/to/agenda/todo"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Create virtual environment
|
|
79
|
+
python3 -m venv .venv
|
|
80
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
81
|
+
|
|
82
|
+
# Install dependencies
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
|
|
85
|
+
# Run tests
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Requirements
|
|
90
|
+
|
|
91
|
+
- Python >= 3.10
|
|
92
|
+
- taskwarrior installed on your system
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
cli/__init__.py,sha256=I9Hh5zrDiIh_ocW0Ra3I-pV0VAJrl1dwLEmCbX-NDn4,14
|
|
2
|
+
cli/download_cmd.py,sha256=kuobLGli_nbrLeeoU1U4GFe46fYDtyj3K4Nlr-KjMhY,1951
|
|
3
|
+
cli/main.py,sha256=zC--P4_g2EtDaiHO0hyBx_4Y458C9YMtl7pDY6Xd84M,125
|
|
4
|
+
cli/root.py,sha256=TlMsPrPVwiIl64ZuwixmftVpmqRwoPmgFLDwKxrzgPY,513
|
|
5
|
+
config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
config/loader.py,sha256=sHueTVLT-spMWn-8Eq2RancJIkVKpgD3XHJIqW3h8SI,1793
|
|
7
|
+
data/bill_profile.py,sha256=DewA8o3MbDel62Q7nFENuefymh0KsYIJWKy0c36H6gw,806
|
|
8
|
+
factory/downloader_factory.py,sha256=iJVxoIkWuW85NwBu_9EzOXcbNU4gR5Gz9FFmUkfEDAg,239
|
|
9
|
+
factory/email_factory.py,sha256=bhnmMO0ldJS0PFxdA9e01DWdHvl3K0RKx781c0ssv4o,345
|
|
10
|
+
factory/provider_factory.py,sha256=oe7oDyCIBeFTjQTGJY0gxkcgrGdhhvOfxS_mZSnooWc,259
|
|
11
|
+
package/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
package/downfile.py,sha256=7qnDJeh1sGnRZDYOPZ5f5QsHuck2iAdfXOIWD6NG3RE,6501
|
|
13
|
+
strategy/downloader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
strategy/downloader/attchment.py,sha256=v6CQwnyKLLoaTk_4q3K99SUuCoSHo4JSHPr4i05a1JM,1545
|
|
15
|
+
strategy/downloader/provider.py,sha256=YasGNxaYvvUgHQxPck8GkD5FW7pCyR9DS9JrbyUTYT4,225
|
|
16
|
+
strategy/email/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
strategy/email/email_provider.py,sha256=aoGFk_D2nifwGQZFi8LH6Xu-2_CHqCWMJSYaIPjKIu0,151
|
|
18
|
+
strategy/email/gmail.py,sha256=-DJiv6f8VbY0ZCdz7rbikfv1PWgkQ5qr3hwkgH06xAI,458
|
|
19
|
+
strategy/provider/ali_provider.py,sha256=WvEeeDsWUVxBMsSQNHSQe36ANyFk1ulevi_J1NGvgpA,797
|
|
20
|
+
strategy/provider/provider.py,sha256=DHBGAr2dfw0W0XULwLd8DwzZY_VH4qEV5tarx8hUU_o,122
|
|
21
|
+
download_flow_enmu-0.1.0.dist-info/METADATA,sha256=VGpCyPLOgmHZcqZsbchSUUlGzj3oXlBYz_iehdnyUnE,1969
|
|
22
|
+
download_flow_enmu-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
23
|
+
download_flow_enmu-0.1.0.dist-info/entry_points.txt,sha256=8SQ6Y1ma7AZSTzdbb0fRbAzeCKnxtNYchJjhi4ciosw,40
|
|
24
|
+
download_flow_enmu-0.1.0.dist-info/top_level.txt,sha256=lAF0lGukoa1q1EfG1UxNkro603Rnr3H0HaXMu9hDAno,41
|
|
25
|
+
download_flow_enmu-0.1.0.dist-info/RECORD,,
|
factory/email_factory.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from strategy.email.gmail import Gmail
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def create_emial(email: str, auth_config: dict, auth_type: str):
|
|
5
|
+
if email == "gmail" and auth_type == "pass":
|
|
6
|
+
return Gmail(
|
|
7
|
+
username=auth_config.get("username", ""),
|
|
8
|
+
password=auth_config.get("password", ""),
|
|
9
|
+
)
|
|
10
|
+
else:
|
|
11
|
+
raise NameError("未知邮箱")
|
package/__init__.py
ADDED
|
File without changes
|
package/downfile.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import imaplib
|
|
2
|
+
import email
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import datetime
|
|
6
|
+
import re
|
|
7
|
+
from email.header import decode_header
|
|
8
|
+
import requests
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
from config import config
|
|
11
|
+
|
|
12
|
+
# === 配置区域 ===
|
|
13
|
+
username = config["email"]["username"]
|
|
14
|
+
password = config["email"]["password"]
|
|
15
|
+
DOWNLOAD_FOLDER_ALI = config["email"]["download_folder_ali"]
|
|
16
|
+
DOWNLOAD_FOLDER_WECHAT = config["email"]["download_folder_wechat"]
|
|
17
|
+
SEARCH_KEYWORD = config["email"]["search_keyword"]
|
|
18
|
+
IMAP_SERVER = config["email"].get("imap_server", "imap.gmail.com")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clean_text(text):
|
|
22
|
+
if not text:
|
|
23
|
+
return ""
|
|
24
|
+
decoded_list = decode_header(text)
|
|
25
|
+
text_str = ""
|
|
26
|
+
for decoded_part, encoding in decoded_list:
|
|
27
|
+
if isinstance(decoded_part, bytes):
|
|
28
|
+
text_str += decoded_part.decode(
|
|
29
|
+
encoding if encoding else "utf-8", errors="ignore"
|
|
30
|
+
)
|
|
31
|
+
else:
|
|
32
|
+
text_str += str(decoded_part)
|
|
33
|
+
return text_str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_html_content(msg):
|
|
37
|
+
for part in msg.walk():
|
|
38
|
+
# 只要 HTML 格式的部分
|
|
39
|
+
if part.get_content_type() == "text/html":
|
|
40
|
+
try:
|
|
41
|
+
payload = part.get_payload(decode=True)
|
|
42
|
+
charset = part.get_content_charset() or "utf-8"
|
|
43
|
+
return payload.decode(charset, errors="ignore")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logging.error(f"HTML解码失败: {e}")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def process_wechat_links(html_content):
|
|
50
|
+
if not html_content:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
54
|
+
|
|
55
|
+
# 提取链接
|
|
56
|
+
links = [a.get("href") for a in soup.find_all("a", href=True)]
|
|
57
|
+
target_links = [
|
|
58
|
+
link
|
|
59
|
+
for link in links
|
|
60
|
+
if "download" in link or "ftn" in link or "qq.com" in link # type: ignore
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if not target_links:
|
|
64
|
+
logging.warning("未找到符合条件的下载链接")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
download_url = target_links[0]
|
|
68
|
+
logging.info(f"提取到下载链接: {download_url}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
headers = {
|
|
72
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
73
|
+
}
|
|
74
|
+
r = requests.get(download_url, headers=headers, stream=True) # type: ignore
|
|
75
|
+
|
|
76
|
+
if r.status_code == 200:
|
|
77
|
+
# import time
|
|
78
|
+
|
|
79
|
+
# timestamp = int(time.time())
|
|
80
|
+
today_str = datetime.date.today().strftime("%Y%m%d")
|
|
81
|
+
filename = f"{today_str}.zip"
|
|
82
|
+
if not os.path.exists(DOWNLOAD_FOLDER_WECHAT):
|
|
83
|
+
os.makedirs(DOWNLOAD_FOLDER_WECHAT)
|
|
84
|
+
|
|
85
|
+
filepath = os.path.join(DOWNLOAD_FOLDER_WECHAT, filename)
|
|
86
|
+
|
|
87
|
+
logging.info(f"准备保存文件到: {filepath}")
|
|
88
|
+
|
|
89
|
+
with open(filepath, "wb") as f:
|
|
90
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
91
|
+
f.write(chunk)
|
|
92
|
+
|
|
93
|
+
logging.info(f"链接下载成功: {filename}")
|
|
94
|
+
return True
|
|
95
|
+
else:
|
|
96
|
+
logging.error(f"下载链接访问失败: {r.status_code}")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logging.error(f"下载异常: {e}")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def download_safe_mode() -> bool:
|
|
105
|
+
try:
|
|
106
|
+
logging.info("正在连接 Gmail...")
|
|
107
|
+
mail = imaplib.IMAP4_SSL(IMAP_SERVER)
|
|
108
|
+
mail.login(username, password)
|
|
109
|
+
mail.select("inbox")
|
|
110
|
+
|
|
111
|
+
# 搜索最近 2 天 (防止时区问题导致搜不到今天)
|
|
112
|
+
target_date = datetime.date.today() - datetime.timedelta(days=2)
|
|
113
|
+
today_str = target_date.strftime("%Y/%m/%d")
|
|
114
|
+
|
|
115
|
+
query_safe = f'X-GM-RAW "after:{today_str}"'
|
|
116
|
+
logging.info(f"步骤1: 向服务器请求邮件 (query: {query_safe})")
|
|
117
|
+
status, messages = mail.search(None, query_safe)
|
|
118
|
+
|
|
119
|
+
if status != "OK":
|
|
120
|
+
logging.error("搜索失败。")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
email_ids = messages[0].split()
|
|
124
|
+
total_found = len(email_ids)
|
|
125
|
+
logging.info(f"步骤1完成: 找到 {total_found} 封邮件。")
|
|
126
|
+
|
|
127
|
+
if total_found == 0:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
logging.info(f"步骤2: 正在本地筛选包含关键词 '{SEARCH_KEYWORD}' 的邮件...\n")
|
|
131
|
+
|
|
132
|
+
match_count = 0
|
|
133
|
+
match_email_id = []
|
|
134
|
+
for email_id in reversed(email_ids):
|
|
135
|
+
res, msg_data = mail.fetch(email_id, "(RFC822)")
|
|
136
|
+
msg = email.message_from_bytes(msg_data[0][1]) # type: ignore
|
|
137
|
+
|
|
138
|
+
subject = clean_text(msg["Subject"])
|
|
139
|
+
|
|
140
|
+
match = re.search(SEARCH_KEYWORD, subject)
|
|
141
|
+
if not match:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
key = match.group()
|
|
145
|
+
logging.info(f"当前关键词为:{key}")
|
|
146
|
+
|
|
147
|
+
match_count += 1
|
|
148
|
+
match_email_id.append(email_id)
|
|
149
|
+
logging.info(f" [√ 匹配] 发现目标邮件: {subject}")
|
|
150
|
+
|
|
151
|
+
# === 分支逻辑 ===
|
|
152
|
+
if key == "微信":
|
|
153
|
+
logging.info("检测到微信账单,尝试提取下载链接...")
|
|
154
|
+
html_content = get_html_content(msg)
|
|
155
|
+
link_success = False
|
|
156
|
+
if html_content:
|
|
157
|
+
link_success = process_wechat_links(html_content)
|
|
158
|
+
if not link_success:
|
|
159
|
+
logging.info("链接提取失败或未找到,尝试检查是否有普通附件...")
|
|
160
|
+
|
|
161
|
+
else:
|
|
162
|
+
logging.info("检测到支付宝或其他,执行附件下载...")
|
|
163
|
+
for part in msg.walk():
|
|
164
|
+
if (
|
|
165
|
+
part.get_content_maintype() == "multipart"
|
|
166
|
+
or part.get("Content-Disposition") is None
|
|
167
|
+
):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
filename = part.get_filename()
|
|
171
|
+
if filename:
|
|
172
|
+
filename = clean_text(filename)
|
|
173
|
+
if not os.path.exists(DOWNLOAD_FOLDER_ALI):
|
|
174
|
+
os.makedirs(DOWNLOAD_FOLDER_ALI)
|
|
175
|
+
|
|
176
|
+
today_str = datetime.date.today().strftime("%Y%m%d")
|
|
177
|
+
filepath = os.path.join(DOWNLOAD_FOLDER_ALI, f"{today_str}.zip")
|
|
178
|
+
|
|
179
|
+
with open(filepath, "wb") as f:
|
|
180
|
+
f.write(part.get_payload(decode=True)) # type: ignore
|
|
181
|
+
|
|
182
|
+
logging.info(f" -> 附件已保存: {today_str}")
|
|
183
|
+
|
|
184
|
+
# ... (后续的归档/移动邮件逻辑保持不变) ...
|
|
185
|
+
|
|
186
|
+
mail.close()
|
|
187
|
+
mail.logout()
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logging.error(f"发生错误: {e}")
|
|
192
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from imap_tools import MailBox, AND
|
|
5
|
+
|
|
6
|
+
from strategy.downloader.provider import Downloader
|
|
7
|
+
from typing import override
|
|
8
|
+
from data.bill_profile import BillProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AttchmentDownloader(Downloader):
|
|
12
|
+
@override
|
|
13
|
+
def download(self, mail: MailBox, bill: BillProfile):
|
|
14
|
+
|
|
15
|
+
criteria = AND(
|
|
16
|
+
from_=bill.sender_email,
|
|
17
|
+
date=datetime.date.today(),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
for msg in mail.fetch(criteria=criteria):
|
|
21
|
+
logging.info(f"找到邮件: {msg.subject}") # 添加这一行
|
|
22
|
+
|
|
23
|
+
if bill.search_subject not in msg.subject:
|
|
24
|
+
continue
|
|
25
|
+
for att in msg.attachments:
|
|
26
|
+
if att.filename.lower().endswith(bill.file_suffix):
|
|
27
|
+
bill.save_subdir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
filepath = (
|
|
29
|
+
bill.save_subdir / f"{datetime.date.today()}.{bill.file_suffix}"
|
|
30
|
+
) # 改这里:加扩展名
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
if bill.file_suffix in ["zip", "7z", "rar"]:
|
|
34
|
+
filepath.write_bytes(att.payload)
|
|
35
|
+
else:
|
|
36
|
+
content_str = att.payload.decode(bill.encoding)
|
|
37
|
+
filepath.write_text(content_str, encoding="utf-8")
|
|
38
|
+
except UnicodeDecodeError:
|
|
39
|
+
logging.error(" ✗ 转码失败")
|
|
40
|
+
else:
|
|
41
|
+
logging.info(f"✗ 附件类型不匹配 (期望后缀: {bill.file_suffix})")
|
|
File without changes
|
strategy/email/gmail.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from strategy.email.email_provider import Email
|
|
5
|
+
from imap_tools import MailBox
|
|
6
|
+
from strategy.downloader.attchment import AttchmentDownloader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Gmail(Email):
|
|
10
|
+
username: str
|
|
11
|
+
password: str
|
|
12
|
+
|
|
13
|
+
def __init__(self, username, password):
|
|
14
|
+
self.username = username
|
|
15
|
+
self.password = password
|
|
16
|
+
self.host = "imap.gmail.com"
|
|
17
|
+
|
|
18
|
+
def connect(self) -> MailBox:
|
|
19
|
+
return MailBox(self.host)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from strategy.provider.provider import Provider
|
|
6
|
+
from strategy.email.email_provider import Email
|
|
7
|
+
from strategy.downloader.provider import Downloader
|
|
8
|
+
from data.bill_profile import BillProfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AliProvider(Provider):
|
|
12
|
+
|
|
13
|
+
downloader: Downloader
|
|
14
|
+
email: Email
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
email: Email = None,
|
|
19
|
+
downloader: Downloader = None,
|
|
20
|
+
bill: BillProfile = None,
|
|
21
|
+
):
|
|
22
|
+
self.email = email
|
|
23
|
+
self.downloader = downloader
|
|
24
|
+
self.bill = bill
|
|
25
|
+
|
|
26
|
+
def process_bills(self):
|
|
27
|
+
logging.info("开始处理ali账单")
|
|
28
|
+
mailbox = self.email.connect()
|
|
29
|
+
|
|
30
|
+
with mailbox.login(self.email.username, self.email.password, "INBOX") as mb:
|
|
31
|
+
self.downloader.download(mb, bill=self.bill)
|