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.
Files changed (48) hide show
  1. ynab_amazon_categorizer-2.3.1/.agents/rules/graphify.md +14 -0
  2. ynab_amazon_categorizer-2.3.1/.agents/workflows/graphify.md +10 -0
  3. ynab_amazon_categorizer-2.3.1/.github/workflows/ci.yml +65 -0
  4. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.gitignore +9 -0
  5. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/CHANGELOG.md +23 -0
  6. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/PKG-INFO +32 -1
  7. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/README.md +31 -0
  8. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/_version.py +3 -3
  9. ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer/amazon_parser.py +240 -0
  10. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/cli.py +319 -115
  11. ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer/transaction_matcher.py +156 -0
  12. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/ynab_client.py +49 -29
  13. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/PKG-INFO +32 -1
  14. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/SOURCES.txt +5 -0
  15. ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer.egg-info/scm_file_list.json +38 -0
  16. ynab_amazon_categorizer-2.3.1/src/ynab_amazon_categorizer.egg-info/scm_version.json +8 -0
  17. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_amazon_parser.py +119 -0
  18. ynab_amazon_categorizer-2.3.1/tests/test_amazon_parser_real.py +175 -0
  19. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_cli_functions.py +300 -4
  20. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_transaction_matcher.py +85 -0
  21. ynab_amazon_categorizer-2.2.4/src/ynab_amazon_categorizer/amazon_parser.py +0 -191
  22. ynab_amazon_categorizer-2.2.4/src/ynab_amazon_categorizer/transaction_matcher.py +0 -93
  23. ynab_amazon_categorizer-2.2.4/tests/test_amazon_parser_real.py +0 -49
  24. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.env.example +0 -0
  25. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.git_archival.txt +0 -0
  26. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.gitattributes +0 -0
  27. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.github/workflows/release.yml +0 -0
  28. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/.pre-commit-config.yaml +0 -0
  29. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/AGENTS.md +0 -0
  30. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/CLAUDE.md +0 -0
  31. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/DEVELOPMENT.md +0 -0
  32. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/LICENSE +0 -0
  33. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/pyproject.toml +0 -0
  34. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/setup.cfg +0 -0
  35. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/__init__.py +0 -0
  36. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/__main__.py +0 -0
  37. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/config.py +0 -0
  38. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/exceptions.py +0 -0
  39. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer/memo_generator.py +0 -0
  40. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/dependency_links.txt +0 -0
  41. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/entry_points.txt +0 -0
  42. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/requires.txt +0 -0
  43. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/src/ynab_amazon_categorizer.egg-info/top_level.txt +0 -0
  44. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/__init__.py +0 -0
  45. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_config.py +0 -0
  46. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_memo_generator.py +0 -0
  47. {ynab_amazon_categorizer-2.2.4 → ynab_amazon_categorizer-2.3.1}/tests/test_ynab_client.py +0 -0
  48. {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
@@ -29,6 +29,15 @@ wheels/
29
29
  venv/
30
30
  env/
31
31
  ENV/
32
+ .venv/
33
+
34
+ # Test / coverage artifacts
35
+ .coverage
36
+ .coverage.*
37
+ htmlcov/
38
+ .pytest_cache/
39
+ .ruff_cache/
40
+ .uv-cache/
32
41
 
33
42
  # IDE
34
43
  .vscode/
@@ -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.2.4
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.2.4'
22
- __version_tuple__ = version_tuple = (2, 2, 4)
21
+ __version__ = version = '2.3.1'
22
+ __version_tuple__ = version_tuple = (2, 3, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g034b5e27c'
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)