webtap-tool 0.1.1__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.

Potentially problematic release.


This version of webtap-tool might be problematic. Click here for more details.

@@ -0,0 +1,427 @@
1
+ Metadata-Version: 2.4
2
+ Name: webtap-tool
3
+ Version: 0.1.1
4
+ Summary: Terminal-based web page inspector for AI debugging sessions
5
+ Author-email: Fredrik Angelsen <fredrikangelsen@gmail.com>
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
9
+ Classifier: Topic :: Software Development :: Debuggers
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: beautifulsoup4>=4.13.5
12
+ Requires-Dist: cryptography>=45.0.6
13
+ Requires-Dist: duckdb>=1.3.2
14
+ Requires-Dist: fastapi>=0.116.1
15
+ Requires-Dist: httpx>=0.28.1
16
+ Requires-Dist: lxml>=6.0.1
17
+ Requires-Dist: msgpack-python>=0.5.6
18
+ Requires-Dist: protobuf>=6.32.0
19
+ Requires-Dist: pyjwt>=2.10.1
20
+ Requires-Dist: pyyaml>=6.0.2
21
+ Requires-Dist: replkit2[all]>=0.11.0
22
+ Requires-Dist: requests>=2.32.4
23
+ Requires-Dist: uvicorn>=0.35.0
24
+ Requires-Dist: websocket-client>=1.8.0
25
+ Requires-Dist: websockets>=15.0.1
26
+ Description-Content-Type: text/markdown
27
+
28
+ # WebTap
29
+
30
+ Browser debugging via Chrome DevTools Protocol with native event storage and dynamic querying.
31
+
32
+ ## Overview
33
+
34
+ WebTap connects to Chrome's debugging protocol and stores CDP events as-is in DuckDB, enabling powerful SQL queries and dynamic field discovery without complex transformations.
35
+
36
+ ## Key Features
37
+
38
+ - **Native CDP Storage** - Events stored exactly as received in DuckDB
39
+ - **Dynamic Field Discovery** - Automatically indexes all field paths from events
40
+ - **Smart Filtering** - Built-in filters for ads, tracking, analytics noise
41
+ - **SQL Querying** - Direct DuckDB access for complex analysis
42
+ - **Chrome Extension** - Visual page selector and connection management
43
+ - **Python Inspection** - Full Python environment for data exploration
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ # Install with uv
49
+ uv tool install webtap
50
+
51
+ # Or from source
52
+ cd packages/webtap
53
+ uv sync
54
+ ```
55
+
56
+ ## Quick Start
57
+
58
+ 1. **Start Chrome with debugging**
59
+ ```bash
60
+ # macOS
61
+ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
62
+
63
+ # Linux
64
+ google-chrome --remote-debugging-port=9222
65
+
66
+ # Windows
67
+ chrome.exe --remote-debugging-port=9222
68
+ ```
69
+
70
+ 2. **Launch WebTap**
71
+ ```bash
72
+ webtap
73
+
74
+ # You'll see:
75
+ ================================================================================
76
+ WebTap - Chrome DevTools Protocol REPL
77
+ --------------------------------------------------------------------------------
78
+ Type help() for available commands
79
+ >>>
80
+ ```
81
+
82
+ 3. **Connect and explore**
83
+ ```python
84
+ >>> pages() # List available Chrome pages
85
+ >>> connect(0) # Connect to first page
86
+ >>> network() # View network requests (filtered)
87
+ >>> console() # View console messages
88
+ >>> events({"url": "*api*"}) # Query any CDP field dynamically
89
+ ```
90
+
91
+ ## Core Commands
92
+
93
+ ### Connection & Navigation
94
+ ```python
95
+ pages() # List Chrome pages
96
+ connect(0) # Connect by index (shorthand)
97
+ connect(page=1) # Connect by index (explicit)
98
+ connect(page_id="xyz") # Connect by page ID
99
+ disconnect() # Disconnect from current page
100
+ navigate("https://...") # Navigate to URL
101
+ reload(ignore_cache=False) # Reload page
102
+ back() / forward() # Navigate history
103
+ page() # Show current page info
104
+ ```
105
+
106
+ ### Dynamic Event Querying
107
+ ```python
108
+ # Query ANY field across ALL event types using dict filters
109
+ events({"url": "*github*"}) # Find GitHub requests
110
+ events({"status": 404}) # Find all 404s
111
+ events({"type": "xhr", "method": "POST"}) # Find AJAX POSTs
112
+ events({"headers": "*"}) # Extract all headers
113
+
114
+ # Field names are fuzzy-matched and case-insensitive
115
+ events({"URL": "*api*"}) # Works! Finds 'url', 'URL', 'documentURL'
116
+ events({"err": "*"}) # Finds 'error', 'errorText', 'err'
117
+ ```
118
+
119
+ ### Network Monitoring
120
+ ```python
121
+ network() # Filtered network requests (default)
122
+ network(no_filters=True) # Show everything (noisy!)
123
+ network(filters=["ads", "tracking"]) # Specific filter categories
124
+ ```
125
+
126
+ ### Filter Management
127
+ ```python
128
+ # Manage noise filters
129
+ filters() # Show current filters (default action="list")
130
+ filters(action="load") # Load from .webtap/filters.json
131
+ filters(action="add", config={"domain": "*doubleclick*", "category": "ads"})
132
+ filters(action="save") # Persist to disk
133
+ filters(action="toggle", config={"category": "ads"}) # Toggle category
134
+
135
+ # Built-in categories: ads, tracking, analytics, telemetry, cdn, fonts, images
136
+ ```
137
+
138
+ ### Data Inspection
139
+ ```python
140
+ # Inspect events by rowid
141
+ inspect(49) # View event details by rowid
142
+ inspect(50, expr="data['params']['response']['headers']") # Extract field
143
+
144
+ # Response body inspection with Python expressions
145
+ body(49) # Get response body
146
+ body(49, expr="import json; json.loads(body)") # Parse JSON
147
+ body(49, expr="len(body)") # Check size
148
+
149
+ # Request interception
150
+ fetch("enable") # Enable request interception
151
+ fetch("disable") # Disable request interception
152
+ requests() # Show paused requests
153
+ resume(123) # Continue paused request by ID
154
+ fail(123) # Fail paused request by ID
155
+ ```
156
+
157
+ ### Console & JavaScript
158
+ ```python
159
+ console() # View console messages
160
+ js("document.title") # Evaluate JavaScript (returns value)
161
+ js("console.log('Hello')", wait_return=False) # Execute without waiting
162
+ clear() # Clear events (default)
163
+ clear(console=True) # Clear browser console
164
+ clear(events=True, console=True, cache=True) # Clear everything
165
+ ```
166
+
167
+ ## Architecture
168
+
169
+ ### Native CDP Storage Philosophy
170
+
171
+ ```
172
+ Chrome Tab
173
+ ↓ CDP Events (WebSocket)
174
+ DuckDB Storage (events table)
175
+ ↓ SQL Queries + Field Discovery
176
+ Service Layer (WebTapService)
177
+ ├── NetworkService - Request filtering
178
+ ├── ConsoleService - Message handling
179
+ ├── FetchService - Request interception
180
+ └── BodyService - Response caching
181
+
182
+ Commands (Thin Wrappers)
183
+ ├── events() - Query any field
184
+ ├── network() - Filtered requests
185
+ ├── console() - Messages
186
+ ├── body() - Response bodies
187
+ └── js() - JavaScript execution
188
+
189
+ API Server (FastAPI on :8765)
190
+ └── Chrome Extension Integration
191
+ ```
192
+
193
+ ### How It Works
194
+
195
+ 1. **Events stored as-is** - No transformation, full CDP data preserved
196
+ 2. **Field paths indexed** - Every unique path like `params.response.status` tracked
197
+ 3. **Dynamic discovery** - Fuzzy matching finds fields without schemas
198
+ 4. **SQL generation** - User queries converted to DuckDB JSON queries
199
+ 5. **On-demand fetching** - Bodies, cookies fetched only when needed
200
+
201
+ ## Advanced Usage
202
+
203
+ ### Direct SQL Queries
204
+ ```python
205
+ # Access DuckDB directly
206
+ sql = """
207
+ SELECT json_extract_string(event, '$.params.response.url') as url,
208
+ json_extract_string(event, '$.params.response.status') as status
209
+ FROM events
210
+ WHERE json_extract_string(event, '$.method') = 'Network.responseReceived'
211
+ """
212
+ results = state.cdp.query(sql)
213
+ ```
214
+
215
+ ### Field Discovery
216
+ ```python
217
+ # See what fields are available
218
+ state.cdp.field_paths.keys() # All discovered field names
219
+
220
+ # Find all paths for a field
221
+ state.cdp.discover_field_paths("url")
222
+ # Returns: ['params.request.url', 'params.response.url', 'params.documentURL', ...]
223
+ ```
224
+
225
+ ### Direct CDP Access
226
+ ```python
227
+ # Send CDP commands directly
228
+ state.cdp.execute("Network.getResponseBody", {"requestId": "123"})
229
+ state.cdp.execute("Storage.getCookies", {})
230
+ state.cdp.execute("Runtime.evaluate", {"expression": "window.location.href"})
231
+ ```
232
+
233
+ ### Chrome Extension
234
+
235
+ Install the extension from `packages/webtap/extension/`:
236
+ 1. Open `chrome://extensions/`
237
+ 2. Enable Developer mode
238
+ 3. Load unpacked → Select extension folder
239
+ 4. Click extension icon to connect to pages
240
+
241
+ ## Examples
242
+
243
+ ### List and Connect to Pages
244
+ ```python
245
+ >>> pages()
246
+ ## Chrome Pages
247
+
248
+ | Index | Title | URL | ID | Connected |
249
+ |:------|:---------------------|:-------------------------------|:-------|:----------|
250
+ | 0 | Messenger | https://www.m...1743198803269/ | DC8... | No |
251
+ | 1 | GitHub - replkit2 | https://githu...elsen/replkit2 | DD4... | No |
252
+ | 2 | YouTube Music | https://music.youtube.com/ | F83... | No |
253
+
254
+ _3 pages available_
255
+ <pages: 1 fields>
256
+
257
+ >>> connect(1)
258
+ ## Connection Established
259
+
260
+ **Page:** GitHub - angelsen/replkit2
261
+
262
+ **URL:** https://github.com/angelsen/replkit2
263
+ <connect: 1 fields>
264
+ ```
265
+
266
+ ### Monitor Network Traffic
267
+ ```python
268
+ >>> network()
269
+ ## Network Requests
270
+
271
+ | ID | ReqID | Method | Status | URL | Type | Size |
272
+ |:-----|:-------------|:-------|:-------|:------------------------------------------------|:---------|:-----|
273
+ | 3264 | 682214.9033 | GET | 200 | https://api.github.com/graphql | Fetch | 22KB |
274
+ | 2315 | 682214.8985 | GET | 200 | https://api.github.com/repos/angelsen/replkit2 | Fetch | 16KB |
275
+ | 359 | 682214.8638 | GET | 200 | https://github.githubassets.com/assets/app.js | Script | 21KB |
276
+
277
+ _3 requests_
278
+
279
+ ### Next Steps
280
+
281
+ - **Analyze responses:** `body(3264)` - fetch response body
282
+ - **Parse HTML:** `body(3264, "bs4(body, 'html.parser').find('title').text")`
283
+ - **Extract JSON:** `body(3264, "json.loads(body)['data']")`
284
+ - **Find patterns:** `body(3264, "re.findall(r'/api/\\w+', body)")`
285
+ - **Decode JWT:** `body(3264, "jwt.decode(body, options={'verify_signature': False})")`
286
+ - **Search events:** `events({'url': '*api*'})` - find all API calls
287
+ - **Intercept traffic:** `fetch('enable')` then `requests()` - pause and modify
288
+ <network: 1 fields>
289
+ ```
290
+
291
+ ### View Console Messages
292
+ ```python
293
+ >>> console()
294
+ ## Console Messages
295
+
296
+ | ID | Level | Source | Message | Time |
297
+ |:-----|:-----------|:---------|:----------------------------------------------------------------|:---------|
298
+ | 5939 | WARNING | security | An iframe which has both allow-scripts and allow-same-origin... | 11:42:46 |
299
+ | 2319 | LOG | console | API request completed | 11:42:40 |
300
+ | 32 | ERROR | network | Failed to load resource: the server responded with a status... | 12:47:41 |
301
+
302
+ _3 messages_
303
+
304
+ ### Next Steps
305
+
306
+ - **Inspect error:** `inspect(32)` - view full stack trace
307
+ - **Find all errors:** `events({'level': 'error'})` - filter console errors
308
+ - **Extract stack:** `inspect(32, "data.get('stackTrace', {})")`
309
+ - **Search messages:** `events({'message': '*failed*'})` - pattern match
310
+ - **Check network:** `network()` - may show failed requests causing errors
311
+ <console: 1 fields>
312
+ ```
313
+
314
+ ### Find and Analyze API Calls
315
+ ```python
316
+ >>> events({"url": "*api*", "method": "POST"})
317
+ ## Query Results
318
+
319
+ | RowID | Method | URL | Status |
320
+ |:------|:----------------------------|:--------------------------------|:-------|
321
+ | 49 | Network.requestWillBeSent | https://api.github.com/graphql | - |
322
+ | 50 | Network.responseReceived | https://api.github.com/graphql | 200 |
323
+
324
+ _2 events_
325
+ <events: 1 fields>
326
+
327
+ >>> body(50, expr="import json; json.loads(body)['data']")
328
+ {'viewer': {'login': 'octocat', 'name': 'The Octocat'}}
329
+
330
+ >>> inspect(49) # View full request details
331
+ ```
332
+
333
+ ### Debug Failed Requests
334
+ ```python
335
+ >>> events({"status": 404})
336
+ ## Query Results
337
+
338
+ | RowID | Method | URL | Status |
339
+ |:------|:-------------------------|:----------------------------------|:-------|
340
+ | 32 | Network.responseReceived | https://api.example.com/missing | 404 |
341
+ | 29 | Network.responseReceived | https://api.example.com/notfound | 404 |
342
+
343
+ _2 events_
344
+ <events: 1 fields>
345
+
346
+ >>> events({"errorText": "*"}) # Find network errors
347
+ >>> events({"type": "Failed"}) # Find failed resources
348
+ ```
349
+
350
+ ### Monitor Specific Domains
351
+ ```python
352
+ >>> events({"url": "*myapi.com*"}) # Your API
353
+ >>> events({"url": "*localhost*"}) # Local development
354
+ >>> events({"url": "*stripe*"}) # Payment APIs
355
+ ```
356
+
357
+ ### Extract Headers and Cookies
358
+ ```python
359
+ >>> events({"headers": "*authorization*"}) # Find auth headers
360
+ >>> state.cdp.execute("Storage.getCookies", {}) # Get all cookies
361
+ >>> events({"setCookie": "*"}) # Find Set-Cookie headers
362
+ ```
363
+
364
+ ## Filter Configuration
365
+
366
+ WebTap includes aggressive default filters to reduce noise. Customize in `.webtap/filters.json`:
367
+
368
+ ```json
369
+ {
370
+ "ads": {
371
+ "domains": ["*doubleclick*", "*googlesyndication*", "*adsystem*"],
372
+ "types": ["Ping", "Beacon"]
373
+ },
374
+ "tracking": {
375
+ "domains": ["*google-analytics*", "*segment*", "*mixpanel*"],
376
+ "types": ["Image", "Script"]
377
+ }
378
+ }
379
+ ```
380
+
381
+ ## Design Principles
382
+
383
+ 1. **Store AS-IS** - No transformation of CDP events
384
+ 2. **Query On-Demand** - Extract only what's needed
385
+ 3. **Dynamic Discovery** - No predefined schemas
386
+ 4. **SQL-First** - Leverage DuckDB's JSON capabilities
387
+ 5. **Minimal Memory** - Store only CDP data
388
+
389
+ ## Requirements
390
+
391
+ - Chrome/Chromium with debugging enabled
392
+ - Python 3.12+
393
+ - Dependencies: websocket-client, duckdb, replkit2, fastapi, uvicorn, beautifulsoup4
394
+
395
+ ## Development
396
+
397
+ ```bash
398
+ # Run from source
399
+ cd packages/webtap
400
+ uv run webtap
401
+
402
+ # API server starts automatically on port 8765
403
+ # Chrome extension connects to http://localhost:8765
404
+
405
+ # Type checking and linting
406
+ basedpyright packages/webtap/src/webtap
407
+ ruff check --fix packages/webtap/src/webtap
408
+ ruff format packages/webtap/src/webtap
409
+ ```
410
+
411
+ ## API Server
412
+
413
+ WebTap automatically starts a FastAPI server on port 8765 for Chrome extension integration:
414
+
415
+ - `GET /status` - Connection status
416
+ - `GET /pages` - List available Chrome pages
417
+ - `POST /connect` - Connect to a page
418
+ - `POST /disconnect` - Disconnect from current page
419
+ - `POST /clear` - Clear events/console/cache
420
+ - `GET /fetch/paused` - Get paused requests
421
+ - `POST /filters/toggle/{category}` - Toggle filter categories
422
+
423
+ The API server runs in a background thread and doesn't block the REPL.
424
+
425
+ ## License
426
+
427
+ MIT - See [LICENSE](../../LICENSE) for details.
@@ -0,0 +1,43 @@
1
+ webtap/VISION.md,sha256=kfoJfEPVc4chOrD9tNMDmYBY9rX9KB-286oZj70ALCE,7681
2
+ webtap/__init__.py,sha256=iMD7Y3DMnmd-psxLdG9uLeo9qXfCDDzUQ52lVo5LWOU,1633
3
+ webtap/api.py,sha256=I4CyOlgJW_peDn94DIeq5945G9BAbCzOqMmf_7YUm_g,6060
4
+ webtap/app.py,sha256=RgLGWUc39o1AOnXxpXg7WVbNk3X8If3Q7nbGeqPaHMw,2586
5
+ webtap/filters.py,sha256=nphF2bFRbFtS2ue-CbV1uzKpuK3IYBbwjLeLhMDdLEk,11034
6
+ webtap/cdp/README.md,sha256=0TS0V_dRgRAzBqhddpXWD4S0YVi5wI4JgFJSll_KUBE,5660
7
+ webtap/cdp/__init__.py,sha256=c6NFG0XJnAa5GTe9MLr9mDZcLZqoTQN7A1cvvOfLcgY,453
8
+ webtap/cdp/query.py,sha256=ULfc8ddHnX_uHezaVLblPOzV6tTyu3ohOc6XlE3wPa0,4153
9
+ webtap/cdp/session.py,sha256=BTajmDH6nel2dvia7IX9k6C-RinkN2NxY4rxtUJAJE0,12362
10
+ webtap/cdp/schema/README.md,sha256=hnWCzbXYcYtWaZb_SgjVaFBiG81S9b9Y3x-euQFwQDo,1222
11
+ webtap/cdp/schema/cdp_protocol.json,sha256=dp9_OLYLuVsQb1oV5r6MZfMzURscBLyAXUckdaPWyv4,1488452
12
+ webtap/cdp/schema/cdp_version.json,sha256=OhGy1qpfQjSe3Z7OqL6KynBFlDFBXxKGPZCY-ZN_lVU,399
13
+ webtap/commands/DEVELOPER_GUIDE.md,sha256=lii-bOUVNBiFjpZlYIipnFNNfUI9vI5VwAhGi2jvQAU,9870
14
+ webtap/commands/TIPS.md,sha256=gE75m66QxU-7RG8NS9z5EchMoEN2OrQ0ZzHMETsAd_w,6854
15
+ webtap/commands/__init__.py,sha256=rr3xM_bY0BgxkDOjsnsI8UBhjlz7nqiYlgJ8fjiJ1jQ,270
16
+ webtap/commands/_builders.py,sha256=nUZLcMWXVWx6KmEV9KLajV-Gbxv2m2NB5lNZgR5Be1c,3842
17
+ webtap/commands/_errors.py,sha256=W64bGcUDsvdncD3WiL4H7hVTVE58OiwWYC391SAk43s,3400
18
+ webtap/commands/_tips.py,sha256=SleMpwdghrHNqdzR60Cu8T0NZqJfWfcfrgIcyWI6GIQ,4793
19
+ webtap/commands/_utils.py,sha256=VLXDhhhJITrQjwEyeLRTU2ey0QcLzY-_OxTjtPJlhYM,6816
20
+ webtap/commands/body.py,sha256=e67q_rT8vtRz5_ViQjl5a6BAolW4uwedRZkVp8TJ5Vo,7111
21
+ webtap/commands/connection.py,sha256=ZYV2TmK1LRVFyMneNYswJmnaoi45rFTApQew5Gm-CC0,5465
22
+ webtap/commands/console.py,sha256=moGLsZ-k5wtukjrPFkEXjBl-Jj_yj4bHEArPXVmZLUc,2180
23
+ webtap/commands/events.py,sha256=yx3iJgTANKsoGXBMu1WfBOjEW_thmNKMmUTXtamqRtQ,4093
24
+ webtap/commands/fetch.py,sha256=_TzOvJfVzPaw4ZmyI95Qb7rS3iKx2nmp_IL3jaQO_6g,7772
25
+ webtap/commands/filters.py,sha256=trZvcbHTaF0FZC8dyMAmhmS2dlBoA82VXe5_DDS3eU8,8986
26
+ webtap/commands/inspect.py,sha256=6PGN7iDT1oLzQJboNeYozLILrW2VsAzmtMpF3_XhD30,5746
27
+ webtap/commands/javascript.py,sha256=QpQdqqoQwwTyz1lpibZ92XKOL89scu_ndgSjkhaYuDk,3195
28
+ webtap/commands/launch.py,sha256=-DonCgu7LakyTIN5ksGjY-8CiQzQiQ2SAamBCOvLWrw,2687
29
+ webtap/commands/navigation.py,sha256=Mapawp2AZTJQaws2uwlTgMUhqz7HlVTLxiZ06n_MQc0,6071
30
+ webtap/commands/network.py,sha256=hwZshGGdVsJ_9MFjOKJXT07I990JjZInw2LLnKXLQ5Y,2910
31
+ webtap/commands/setup.py,sha256=ewoOTvgCAzMPcFm6cbk-93nY_BI2IXqPZGGGpc1rJiw,4459
32
+ webtap/services/README.md,sha256=rala_jtnNgSiQ1lFLM7x_UQ4SJZDceAm7dpkQMRTYaI,2346
33
+ webtap/services/__init__.py,sha256=IjFqu0Ak6D-r18aokcQMtenDV3fbelvfjTCejGv6CZ0,570
34
+ webtap/services/body.py,sha256=tU60N-efOvTMndDGLcz0rf921VHiRmczDEs-qtECMng,3934
35
+ webtap/services/console.py,sha256=W1FGSuLl1bgZzpD9bVu98wlHGupe12vA7YqDLXFszYs,3789
36
+ webtap/services/fetch.py,sha256=hKrhFda1x65GmLHO0XMkxbclMtkWt0uEv7Rp9Ui2na0,13623
37
+ webtap/services/main.py,sha256=HcXdPuI7hzsxsNvfN0npGhj_M7HObc83Lr3fuy7BMeE,5673
38
+ webtap/services/network.py,sha256=EJZIlaE98v113xypPIiiTE-LiG-eCXGNXoJjzDYebhU,3480
39
+ webtap/services/setup.py,sha256=DF2471WsY5pJ5gEn64EMGRW5t9mUyuTQoCTejOJeQxg,7594
40
+ webtap_tool-0.1.1.dist-info/METADATA,sha256=QYQTlMAdoYseHUo742JZfunjHy6KjKlO89YvKZJDBSE,14277
41
+ webtap_tool-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
42
+ webtap_tool-0.1.1.dist-info/entry_points.txt,sha256=iFe575I0CIb1MbfPt0oX2VYyY5gSU_dA551PKVR83TU,39
43
+ webtap_tool-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ webtap = webtap:main