ynab-amazon-categorizer 2.2.4__tar.gz → 2.3.1__tar.gz
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.
- ynab_amazon_categorizer-2.3.1/.agents/rules/graphify.md +14 -0
- ynab_amazon_categorizer-2.3.1/.agents/workflows/graphify.md +10 -0
- ynab_amazon_categorizer-2.3.1/.github/workflows/ci.yml +65 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.gitignore +9 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/CHANGELOG.md +23 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/PKG-INFO +32 -1
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/README.md +31 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/_version.py +3 -3
- ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer/amazon_parser.py +240 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/cli.py +319 -115
- ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer/transaction_matcher.py +156 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/ynab_client.py +49 -29
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +32 -1
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +5 -0
- ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer.egg-info/scm_file_list.json +38 -0
- ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer.egg-info/scm_version.json +8 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_amazon_parser.py +119 -0
- ynab_amazon_categorizer-2.3.1/tests/test_amazon_parser_real.py +175 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_cli_functions.py +300 -4
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_transaction_matcher.py +85 -0
- ynab_amazon_categorizer-2.2.4/src/ynab_amazon_categorizer/amazon_parser.py +0 -191
- ynab_amazon_categorizer-2.2.4/src/ynab_amazon_categorizer/transaction_matcher.py +0 -93
- ynab_amazon_categorizer-2.2.4/tests/test_amazon_parser_real.py +0 -49
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.env.example +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.git_archival.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.gitattributes +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.github/workflows/release.yml +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.pre-commit-config.yaml +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/AGENTS.md +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/CLAUDE.md +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/DEVELOPMENT.md +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/LICENSE +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/pyproject.toml +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/setup.cfg +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/__init__.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/__main__.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/config.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/exceptions.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/memo_generator.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/requires.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/__init__.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_config.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_memo_generator.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_ynab_client.py +0 -0
- {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/uv.lock +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
trigger: always_on
|
|
3
|
+
description: Consult the graphify knowledge graph at graphify-out/ for codebase and architecture questions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## graphify
|
|
7
|
+
|
|
8
|
+
This project has a graphify knowledge graph at graphify-out/.
|
|
9
|
+
|
|
10
|
+
Rules:
|
|
11
|
+
- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
|
|
12
|
+
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
|
13
|
+
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
|
|
14
|
+
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphify
|
|
3
|
+
description: Turn any folder of files into a navigable knowledge graph
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Workflow: graphify
|
|
7
|
+
|
|
8
|
+
Follow the graphify skill installed at ~/.gemini/config/skills/graphify/SKILL.md to run the full pipeline.
|
|
9
|
+
|
|
10
|
+
If no path argument is given, use `.` (current directory).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: test (${{ matrix.os }}, py${{ matrix.python-version }})
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-latest, windows-latest]
|
|
20
|
+
python-version: ["3.12", "3.13"]
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout code
|
|
23
|
+
uses: actions/checkout@v5
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0 # Fetch full history and tags for setuptools-scm
|
|
26
|
+
- name: Install uv
|
|
27
|
+
uses: astral-sh/setup-uv@v6
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: uv sync --extra dev
|
|
32
|
+
- name: Run tests
|
|
33
|
+
run: uv run python -X utf8 -m pytest tests/ -v
|
|
34
|
+
|
|
35
|
+
lint:
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- name: Checkout code
|
|
39
|
+
uses: actions/checkout@v5
|
|
40
|
+
- name: Install uv
|
|
41
|
+
uses: astral-sh/setup-uv@v6
|
|
42
|
+
with:
|
|
43
|
+
python-version: "3.12"
|
|
44
|
+
- name: Install dependencies
|
|
45
|
+
run: uv sync --extra dev
|
|
46
|
+
- name: Ruff format check
|
|
47
|
+
run: uv run --extra dev ruff format --check src tests
|
|
48
|
+
- name: Ruff lint
|
|
49
|
+
run: uv run --extra dev ruff check src tests
|
|
50
|
+
|
|
51
|
+
type-check:
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- name: Checkout code
|
|
55
|
+
uses: actions/checkout@v5
|
|
56
|
+
with:
|
|
57
|
+
fetch-depth: 0 # Fetch full history and tags for setuptools-scm
|
|
58
|
+
- name: Install uv
|
|
59
|
+
uses: astral-sh/setup-uv@v6
|
|
60
|
+
with:
|
|
61
|
+
python-version: "3.12"
|
|
62
|
+
- name: Install dependencies
|
|
63
|
+
run: uv sync --extra dev
|
|
64
|
+
- name: Run type checker
|
|
65
|
+
run: uv run --extra dev ty check src tests
|
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.1] - 2026-07-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Amazon order item extraction now stops at unparsed order-like blocks and post-pagination recommendation sections, preventing unrelated page content from leaking into YNAB memos.
|
|
15
|
+
|
|
16
|
+
## [2.3.0] - 2026-06-19
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `--dry-run` flag: preview every update without sending changes to YNAB
|
|
21
|
+
- `--batch` flag: non-interactively auto-set memos (items + order link) for confidently matched transactions, leaving categories unchanged; combine with `--dry-run` to preview
|
|
22
|
+
- CI workflow running tests (Ubuntu + Windows, Python 3.12 & 3.13), lint, and type checks on every push and pull request
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- All interactive prompts now go through `prompt_toolkit` for consistent input handling (replaces the builtin `input()`)
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A matched Amazon order is no longer reused for multiple same-amount transactions in a single run
|
|
31
|
+
- "Subscribe & Save" / delivery-management lines and refund-status lines are no longer extracted as order items
|
|
32
|
+
|
|
10
33
|
## [2.2.4] - 2026-05-13
|
|
11
34
|
|
|
12
35
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ynab-amazon-categorizer
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: Automatically categorize Amazon transactions in YNAB with rich item information
|
|
5
5
|
Author: dizzlkheinz
|
|
6
6
|
License: GPL-3.0
|
|
@@ -138,6 +138,37 @@ ynab-amazon-categorizer
|
|
|
138
138
|
python -m ynab_amazon_categorizer
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
### Dry run
|
|
142
|
+
|
|
143
|
+
Use `--dry-run` to walk through the full interactive flow and preview every
|
|
144
|
+
update without sending any changes to YNAB:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
ynab-amazon-categorizer --dry-run
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The tool still shows the JSON preview for each transaction but skips the API
|
|
151
|
+
call, so it is safe for trying the tool out or verifying matches.
|
|
152
|
+
|
|
153
|
+
### Batch mode
|
|
154
|
+
|
|
155
|
+
Use `--batch` to run non-interactively: for every transaction with a single
|
|
156
|
+
high-confidence order match (unique exact-amount match within ~7 days), the
|
|
157
|
+
tool sets the memo (items + order link) automatically and **leaves the category
|
|
158
|
+
unchanged**, so you can still review/categorize later. Transactions with no
|
|
159
|
+
match or an ambiguous match are skipped.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Preview what batch mode would enrich
|
|
163
|
+
ynab-amazon-categorizer --batch --dry-run
|
|
164
|
+
|
|
165
|
+
# Apply memo enrichment
|
|
166
|
+
ynab-amazon-categorizer --batch
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
You still paste the Amazon orders page once when prompted; `--batch` only
|
|
170
|
+
removes the per-transaction prompting.
|
|
171
|
+
|
|
141
172
|
### Workflow
|
|
142
173
|
1. **Provide Amazon Orders Data** (optional but recommended):
|
|
143
174
|
- Copy your Amazon orders page content
|
|
@@ -104,6 +104,37 @@ ynab-amazon-categorizer
|
|
|
104
104
|
python -m ynab_amazon_categorizer
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
### Dry run
|
|
108
|
+
|
|
109
|
+
Use `--dry-run` to walk through the full interactive flow and preview every
|
|
110
|
+
update without sending any changes to YNAB:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
ynab-amazon-categorizer --dry-run
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The tool still shows the JSON preview for each transaction but skips the API
|
|
117
|
+
call, so it is safe for trying the tool out or verifying matches.
|
|
118
|
+
|
|
119
|
+
### Batch mode
|
|
120
|
+
|
|
121
|
+
Use `--batch` to run non-interactively: for every transaction with a single
|
|
122
|
+
high-confidence order match (unique exact-amount match within ~7 days), the
|
|
123
|
+
tool sets the memo (items + order link) automatically and **leaves the category
|
|
124
|
+
unchanged**, so you can still review/categorize later. Transactions with no
|
|
125
|
+
match or an ambiguous match are skipped.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Preview what batch mode would enrich
|
|
129
|
+
ynab-amazon-categorizer --batch --dry-run
|
|
130
|
+
|
|
131
|
+
# Apply memo enrichment
|
|
132
|
+
ynab-amazon-categorizer --batch
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
You still paste the Amazon orders page once when prompted; `--batch` only
|
|
136
|
+
removes the per-transaction prompting.
|
|
137
|
+
|
|
107
138
|
### Workflow
|
|
108
139
|
1. **Provide Amazon Orders Data** (optional but recommended):
|
|
109
140
|
- Copy your Amazon orders page content
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.3.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g70c0a58c8'
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Amazon order parsing functionality."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# Maximum items to extract per order (keeps memos manageable)
|
|
9
|
+
MAX_ITEMS_PER_ORDER = 10
|
|
10
|
+
|
|
11
|
+
ORDER_CONTENT_BOUNDARY_PATTERN = re.compile(
|
|
12
|
+
r"^\s*(?:Order placed|Subscription charged on|Digital order placed)\b",
|
|
13
|
+
re.IGNORECASE | re.MULTILINE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
ORDER_TAIL_SENTINEL_PATTERN = re.compile(
|
|
17
|
+
r"^\s*(?:"
|
|
18
|
+
r"[←<]?\s*Previous\b.*|"
|
|
19
|
+
r"Next\s*[→>]?\s*$|"
|
|
20
|
+
r"Sponsored\s*$|"
|
|
21
|
+
r"Learn more[ \t]*(?:\r?\n)[ \t]*\$\d|"
|
|
22
|
+
r"Top .+ For You\s*$|"
|
|
23
|
+
r"Get to Know Us\s*$|"
|
|
24
|
+
r"Make Money with Us\s*$|"
|
|
25
|
+
r"Amazon Payment Products\s*$|"
|
|
26
|
+
r"Let Us Help You\s*$"
|
|
27
|
+
r")",
|
|
28
|
+
re.IGNORECASE | re.MULTILINE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Order:
|
|
33
|
+
"""Represents a parsed Amazon order."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.order_id: str | None = None
|
|
37
|
+
self.total: float | None = None
|
|
38
|
+
self.date_str: str | None = None
|
|
39
|
+
self.items: list[str] = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AmazonParser:
|
|
43
|
+
"""Parses Amazon order data from order history pages."""
|
|
44
|
+
|
|
45
|
+
def _remove_cancelled_orders(self, text: str) -> str:
|
|
46
|
+
"""Remove cancelled order blocks so their items don't bleed into adjacent orders."""
|
|
47
|
+
parts = re.split(r"(?=Order placed)", text)
|
|
48
|
+
kept = []
|
|
49
|
+
for part in parts:
|
|
50
|
+
if (
|
|
51
|
+
re.match(r"\s*Order placed", part)
|
|
52
|
+
and "Your order was cancelled" in part
|
|
53
|
+
):
|
|
54
|
+
continue
|
|
55
|
+
kept.append(part)
|
|
56
|
+
return "".join(kept)
|
|
57
|
+
|
|
58
|
+
def parse_orders_page(self, orders_text: str) -> list[Order]:
|
|
59
|
+
"""Parse Amazon orders page text to extract order information.
|
|
60
|
+
|
|
61
|
+
Orders are kept even when item extraction fails (partial orders)
|
|
62
|
+
so that amount/date matching can still work.
|
|
63
|
+
"""
|
|
64
|
+
if not orders_text.strip():
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
orders_text = self._remove_cancelled_orders(orders_text)
|
|
68
|
+
|
|
69
|
+
orders = []
|
|
70
|
+
|
|
71
|
+
# Find all order blocks using regex
|
|
72
|
+
order_pattern = r"Order placed\s*([A-Za-z]+ \d+, \d{4})\s*Total\s*\$([0-9][0-9,]*(?:\.[0-9]{1,2})?)\s*.*?Order # (\d{3}-\d{7}-\d{7})"
|
|
73
|
+
order_matches = list(
|
|
74
|
+
re.finditer(order_pattern, orders_text, re.DOTALL | re.IGNORECASE)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for idx, match in enumerate(order_matches):
|
|
78
|
+
order_date = match.group(1).strip()
|
|
79
|
+
order_total = float(match.group(2).replace(",", ""))
|
|
80
|
+
order_id = match.group(3)
|
|
81
|
+
|
|
82
|
+
# Find the content after this order until the next order-like block or end
|
|
83
|
+
start_pos = match.end()
|
|
84
|
+
if idx + 1 < len(order_matches):
|
|
85
|
+
end_pos = order_matches[idx + 1].start()
|
|
86
|
+
else:
|
|
87
|
+
end_pos = len(orders_text)
|
|
88
|
+
end_pos = self._find_order_content_end(orders_text, start_pos, end_pos)
|
|
89
|
+
order_content = orders_text[start_pos:end_pos]
|
|
90
|
+
|
|
91
|
+
# Extract items from the order content
|
|
92
|
+
items = self.extract_items_from_content(order_content)
|
|
93
|
+
|
|
94
|
+
# Always keep the order even without items (partial order)
|
|
95
|
+
order = Order()
|
|
96
|
+
order.order_id = order_id
|
|
97
|
+
order.total = order_total
|
|
98
|
+
order.date_str = order_date
|
|
99
|
+
order.items = items
|
|
100
|
+
|
|
101
|
+
if not items:
|
|
102
|
+
logger.info(
|
|
103
|
+
"Order %s parsed without items (amount=%.2f). "
|
|
104
|
+
"It can still match by amount/date.",
|
|
105
|
+
order_id,
|
|
106
|
+
order_total,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
orders.append(order)
|
|
110
|
+
|
|
111
|
+
return orders
|
|
112
|
+
|
|
113
|
+
def _find_order_content_end(
|
|
114
|
+
self, orders_text: str, start_pos: int, default_end: int
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Find the earliest unparsed order-like boundary before the default end."""
|
|
117
|
+
boundary = ORDER_CONTENT_BOUNDARY_PATTERN.search(
|
|
118
|
+
orders_text, start_pos, default_end
|
|
119
|
+
)
|
|
120
|
+
if boundary:
|
|
121
|
+
return boundary.start()
|
|
122
|
+
return default_end
|
|
123
|
+
|
|
124
|
+
def _trim_footer(self, order_content: str) -> str:
|
|
125
|
+
"""Trim at page footer sentinels to avoid extracting navigation/legal boilerplate."""
|
|
126
|
+
footer_sentinel = ORDER_TAIL_SENTINEL_PATTERN.search(order_content)
|
|
127
|
+
if not footer_sentinel:
|
|
128
|
+
footer_sentinel = re.search(
|
|
129
|
+
r"©\s*\d{4}|To move between items",
|
|
130
|
+
order_content,
|
|
131
|
+
re.IGNORECASE,
|
|
132
|
+
)
|
|
133
|
+
if footer_sentinel:
|
|
134
|
+
return order_content[: footer_sentinel.start()]
|
|
135
|
+
return order_content
|
|
136
|
+
|
|
137
|
+
def _get_valid_cleaned_item(self, line: str) -> str | None:
|
|
138
|
+
"""Check if a line matches product name criteria and return the cleaned string, or None."""
|
|
139
|
+
line = line.strip()
|
|
140
|
+
if not line or len(line) < 15:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# Skip common UI elements and delivery status lines
|
|
144
|
+
skip_patterns = [
|
|
145
|
+
r"^(Buy it again|Track package|View|Return|Write|Get|Share|Leave|Ask)",
|
|
146
|
+
r"^(Delivered|Arriving|Now arriving|Auto-delivered|Package was)",
|
|
147
|
+
r"^(Return items:|Return or replace|Refund issued|Refund:|Returned)",
|
|
148
|
+
r"^(Subscribe & Save|Subscribe now|Skip this delivery|Deliver every"
|
|
149
|
+
r"|Change delivery|Manage subscription|Edit delivery|Set up now)",
|
|
150
|
+
r"^\d+\.?\d* out of \d+ stars",
|
|
151
|
+
r"^FREE|^Today by|^Get it|^List:|^Was:|^Limited-time deal",
|
|
152
|
+
r"^\$\d+\.\d+|\(\$\d+\.\d+",
|
|
153
|
+
r"^\d+ sustainability features?$",
|
|
154
|
+
r"^[A-Z\s]+$", # All caps lines (must be ONLY caps and spaces)
|
|
155
|
+
r"^(Ship to|Order #|View order|Invoice)",
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
if any(re.match(pattern, line, re.IGNORECASE) for pattern in skip_patterns):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# Look for product names - they usually contain specific patterns
|
|
162
|
+
has_product_pattern = (
|
|
163
|
+
any(
|
|
164
|
+
word in line.lower()
|
|
165
|
+
for word in [
|
|
166
|
+
"pack",
|
|
167
|
+
"count",
|
|
168
|
+
"size",
|
|
169
|
+
"oz",
|
|
170
|
+
"ml",
|
|
171
|
+
"lbs",
|
|
172
|
+
"kg",
|
|
173
|
+
"inch",
|
|
174
|
+
"cm",
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
or re.search(
|
|
178
|
+
r"[A-Z][a-z].*[A-Z]", line
|
|
179
|
+
) # Mixed case indicating product names
|
|
180
|
+
or len(line.split()) >= 5
|
|
181
|
+
) # Long descriptive lines
|
|
182
|
+
|
|
183
|
+
if not has_product_pattern:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Clean up the line
|
|
187
|
+
cleaned_line = re.sub(r"\s+", " ", line)
|
|
188
|
+
cleaned_line = re.sub(r"^[-•]\s*", "", cleaned_line) # Remove bullet points
|
|
189
|
+
|
|
190
|
+
# Skip if it looks like navigation or common elements
|
|
191
|
+
skip_words = [
|
|
192
|
+
"account",
|
|
193
|
+
"orders",
|
|
194
|
+
"cart",
|
|
195
|
+
"search",
|
|
196
|
+
"hello",
|
|
197
|
+
"browse",
|
|
198
|
+
"prime",
|
|
199
|
+
"shipping",
|
|
200
|
+
"mastercard",
|
|
201
|
+
"your brand",
|
|
202
|
+
"registry & gift",
|
|
203
|
+
"attract and engage",
|
|
204
|
+
"interest-based",
|
|
205
|
+
]
|
|
206
|
+
if any(word in cleaned_line.lower() for word in skip_words):
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
return cleaned_line
|
|
210
|
+
|
|
211
|
+
def _deduplicate_and_badge_filter(self, candidates: list[str]) -> list[str]:
|
|
212
|
+
"""Build lookup set to detect quantity-badge duplicates and keep up to MAX_ITEMS_PER_ORDER."""
|
|
213
|
+
# Amazon shows "Product Name <qty>" and "Product Name" on adjacent lines when
|
|
214
|
+
# qty > 1. We want to strip the badge only when the bare form also appears.
|
|
215
|
+
candidate_set = set(candidates)
|
|
216
|
+
seen: set[str] = set()
|
|
217
|
+
unique_items: list[str] = []
|
|
218
|
+
for item in candidates:
|
|
219
|
+
stripped = re.sub(r"\s+\d+$", "", item)
|
|
220
|
+
normalized = (
|
|
221
|
+
stripped if (stripped != item and stripped in candidate_set) else item
|
|
222
|
+
)
|
|
223
|
+
if normalized not in seen and len(normalized) > 15:
|
|
224
|
+
seen.add(normalized)
|
|
225
|
+
unique_items.append(normalized)
|
|
226
|
+
if len(unique_items) >= MAX_ITEMS_PER_ORDER:
|
|
227
|
+
break
|
|
228
|
+
return unique_items
|
|
229
|
+
|
|
230
|
+
def extract_items_from_content(self, order_content: str) -> list[str]:
|
|
231
|
+
"""Extract item names from order content."""
|
|
232
|
+
order_content = self._trim_footer(order_content)
|
|
233
|
+
|
|
234
|
+
candidates: list[str] = []
|
|
235
|
+
for line in order_content.split("\n"):
|
|
236
|
+
cleaned = self._get_valid_cleaned_item(line)
|
|
237
|
+
if cleaned:
|
|
238
|
+
candidates.append(cleaned)
|
|
239
|
+
|
|
240
|
+
return self._deduplicate_and_badge_filter(candidates)
|